100均スピーカー×Raspberry Piでラジオを楽しむ!好きな番組をワンタップ再生

raspberrypiでスピーカーを付けてラジオを楽しむ RaspberryPi

イントロダクション

ラズベリーパイをもっているものの、使い道に悩んでいませんか?
私も同じ悩みを抱えていました。
それならば、やれそうなことはひとつずつ試してみたいと思います!

今回は誰でも簡単に作れる、自作ラジオステーションを紹介します!

100均スピーカーをどうにかしてラズベリーパイで活用してみたかったので、まずは好きなラジオ番組を聞けるようにします。
ブラウザで作成したページにアクセスして、好きな番組の再生ボタンをクリックしたらそのラジオ番組が流れるようにします。
ポイントはワンクリックでラジオ再生です!

それだけだと面白くないので、
現在放送中のラジオ番組の一覧を表示・再生できるようにもしてみました。


準備するもの

特筆して必要なものは次の2つです。
スピーカーはラズベリーパイから音が出せるなら何でも大丈夫!

  • Raspberry Pi
  • 100均スピーカー

100均スピーカーは300円しますが、部屋で聞く分には本当にコスパ最高だと思います!

ミニスピーカー
原産国(地域):中国 材質:ABS 商品サイズ:6.2cm×5.2cm×7.2cm 内容量:2個入 種類(色、柄、デザイン):アソートなし デスクトップに置けるミニスピーカーです。 スペースを取らず出し入れしやすいです。 左右のチャンネル、...


完成した自作ラジオの機能と使い方

ポイントはボタンをクリックするだけでラジオ再生です!

主な機能

お気に入り番組の即時再生

「J-WAVE」「Tokyofm」はお気に入りの番組なので、すぐに再生できるようにボタンを固定して表示しています。
お気に入りの番組はjsonファイルにまとめているので追記するだけでボタンが増えるようになっています。

放送中番組の一覧表示

「放送中のラジオ一覧」は現在の放送中のラジオ番組が一覧で表示されます。
画像だとTBSラジオから下にスクロールすると放送中のラジオ番組が表示されます。
こちらも右側の再生ボタンをクリックするだけで再生できるようになっています。

データ管理機能

せっかくラジオ聞くならデータを取りたいなと思ったので再生時間をとってみることにしました。
「総再生時間」はラズベリーパイを使ってラジオを聞いた時間が表示されます。
データとしては日付ラジオ局ごとにデータ取得しているのであとから日別、月別、ラジオ局別にデータを表示したりすることも可能です。


コーディング

これから紹介するコードをコピペしていくだけで大丈夫です。

プロジェクトディレクトリに/appディレクトリを作成し、そこから必要なフォルダやファイルを作成します。

ディレクトリ構成

プロジェクトのディレクトリ構成は画像の感じで結構適当です。

app/配下: Pythonの実行ファイルなどを設置
app/static/配下:css、イメージファイル、JavaScriptファイルを設置
app/template/配下:サイト表示用のindex.htmlを設置

必要なライブラリのインストール

必要なライブラリをインストールしましょう。

pip install flask requests

index.htmlのコード(サイトページ)

基本的にはボタンの表示、クリック時にJavaScriptが発火される内容です。
また、データがあったときだけ表示するようになっている要素もあり、style=”display:none;”としている箇所もあります。

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ラジオ番組セレクター</title>
    <!-- css -->
    <link rel="stylesheet" href="/static/css/styles.css">
    <!-- ブラウザタブのファビコン -->
    <link rel="icon" href="/static/img/fav.png" type="image/x-icon">
</head>

<body>
    <div class="hedder-radio-menu">
        <h1>ラジオ番組を選択してください</h1>
        <div id="radio-buttons"></div>

        <!-- 再生中のラジオ番組 -->
        <div id="now-playing" style="display:none;">
            <h2>再生中: <span id="current-radio-name"></span></h2>
        </div>

        <!-- 停止ボタン -->
        <button id="stop-button" style="display:none;" onclick="stopRadio()">停止</button>

        <!-- 放送中のラジオ一覧ボタン -->
        <button id="show-live-radi-button" onclick="showLiveRadio()">放送中のラジオ一覧</button>

        <!-- 再生時間表示 -->
        <div id="play-time"
            style="position: fixed; top: 10px; right: 10px; font-size: 18px; background: #ffffff; padding: 5px 10px; border-radius: 5px;">
            総再生時間: <span id="play-time-value">00:00:00</span>
        </div>
    </div>

    <!-- 放送中のラジオ番組のボタンを表示 -->
    <div id="live-radio-buttons" class="live-radio-buttons" style="display:none;"></div>

    <script src="/static/js/app.js"></script>

</body>

</html>

styles.cssのコード(ページのデザイン)

完成イメージとなるデザインが反映されるデザインのファイルです。
色やフォントサイズなどこのファイルを変更すれば好みのデザインにすることができます。

/* ベースのスタイル */
body {
    font-family: Arial, sans-serif;
    background-color: #f0f0f0;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
}

.hedder-radio-menu {
    min-height: 250px;
    justify-items: center;
    position: fixed;
    z-index: 1000;
    width: 100%;
    background: #f0f0f0;
    border-bottom: solid 2px green;
}

h1 {
    margin-top: 30px;
    font-size: 24px;
}

#radio-buttons {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    margin-top: 20px;
}

button {
    padding: 10px 20px;
    background-color: #4CAF50;
    color: white;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    font-size: 16px;
    margin: 5px;
}

button:hover {
    background-color: #45a049;
}

/* 再生中のラジオ番組の表示 */
#now-playing {
    margin-top: 20px;
    font-size: 18px;
    color: #333;
}

.station {
    padding-bottom: 40px;
}

/* 再生ボタンの丸いスタイル */
.play-button {
    /* ボタンを画像の中央に配置 */
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    display: inline-block;
    /* ボタンのデザイン */
    color: white;
    font-size: 20px;
    line-height: 40px;
    text-align: center;
    cursor: pointer;
    border: none;
    transition: background-color 0.3s;
    border-radius: 30px;
    width: 100px;
    margin-left: 30px;
}

.play-button:hover {
    /* ホバー時に色が少し濃くなる */
    background-color: #45a049;
}

.program {
    position: relative;
    padding: 10px;
    margin-bottom: 20px;
    border: 1px solid #ddd;
    border-radius: 8px;
    box-shadow: 0 0px 10px rgba(0, 0, 0, 0.3);
}

.station-img {
    border-radius: 11px;
}

.live-radio-buttons {
    padding-top: 260px;
}

app.jsのコード(JavaScriptのメソッド発火)

ここでは画面側でボタンがクリックされたときに発火させたい処理をまとめています。

  • お気に入りの番組を表示
  • ラジオ番組の再生
  • ラジオ再生の停止
  • 放送中のラジオ番組一覧の表示
  • 総再生時間の表示
// お気に入りの番組を表示
fetch('/static/streams.json')
    .then(response => {
        if (!response.ok) {
            throw new Error('Failed to load streams.json');
        }
        return response.json();
    })
    .then(data => {
        // もし 'data' がオブジェクトで 'streams' 配列を含んでいる場合
        if (!data || !Array.isArray(data.streams)) {
            throw new Error('Invalid JSON structure: missing "streams" array.');
        }

        const streams = data.streams;
        const container = document.getElementById('radio-buttons');
        streams.forEach(stream => {
            const button = document.createElement('button');
            button.textContent = stream.name; // ボタンにラジオ番組の名前を表示
            button.className = 'radio-button'; // CSSクラスを設定
            button.onclick = () => playRadio(stream.id, stream.name); // クリック時にラジオ再生
            container.appendChild(button); // ボタンをコンテナに追加
        });
    })
    .catch(error => console.error('Error fetching streams:', error));

// ラジオ番組の再生
function playRadio(id, name, title = "") {

    // 既に再生中のラジオがあれば停止
    if (window.currentProcess) {
        stopRadio();
        // 総再生時間の表示更新
        updateTotalPlayTime()
    }

    // 新たにラジオを再生、ラジオ局のid、ラジオ局名を取得
    fetch(`/play?station_id=${encodeURIComponent(id)}&radio_name=${encodeURIComponent(name)}`)
        .then(response => response.json())
        .then(data => {
            if (data.status === 'playing') {
                // 再生中のラジオ番組を表示
                document.getElementById('now-playing').style.display = 'block'; // 再生中であることを表示
                document.getElementById('current-radio-name').textContent = name + ": " + title; // 再生中の番組名を表示
                document.getElementById('stop-button').style.display = 'inline-block'; // 停止ボタンを表示

                // 再生中のラジオ番組情報を保存
                window.currentProcess = data.process;
            }
        })
        .catch(error => {
            console.error('Error playing radio:', error);
        });
}

// ラジオ再生の停止
function stopRadio() {
    fetch('/stop')
        .then(response => response.json())
    // 総再生時間の表示更新
    updateTotalPlayTime()
        .then(data => {
            if (data.status === 'stopped') {
                // 停止ボタン、再生中ラジオ名を非表示
                document.getElementById('stop-button').style.display = 'none';
                document.getElementById('now-playing').style.display = 'none';
            }
        })
        .catch(error => console.error('Error stopping radio:', error));
}

// 放送中のラジオ番組一覧の表示
function showLiveRadio() {
    fetch('/get_radio_stations')
        .then(response => response.json())
        .then(data => {
            const liveRadioDiv = document.getElementById('live-radio-buttons');
            liveRadioDiv.innerHTML = '';  // 既存の内容をクリア

            data.stations.forEach(station => {
                const stationDiv = document.createElement('div');
                stationDiv.classList.add('station');

                const stationName = document.createElement('h2');
                stationName.textContent = station.name;
                stationDiv.appendChild(stationName);

                // 番組情報を表示
                station.programs.forEach(program => {
                    const programDiv = document.createElement('div');
                    programDiv.classList.add('program');

                    // 番組名をh3タグのリンクにする
                    const programLink = document.createElement('a');
                    programLink.href = program.url;  // APIで取得したURLをリンク先に
                    programLink.target = "_blank";  // 新しいタブで開く

                    // h3タグを作成してリンクを追加
                    const programTitle = document.createElement('h3');
                    programTitle.appendChild(programLink);
                    programLink.textContent = program.title;  // 番組名
                    programDiv.appendChild(programTitle);

                    const programPfm = document.createElement('p');
                    programPfm.textContent = `出演者: ${program.pfm}`;
                    programDiv.appendChild(programPfm);


                    if (program.img) {
                        const programImg = document.createElement('img');
                        programImg.classList.add('station-img');
                        programImg.src = program.img;
                        programImg.alt = program.title;
                        programDiv.appendChild(programImg);
                    }

                    const playButton = document.createElement('button');
                    playButton.classList.add('play-button');
                    playButton.textContent = '▶';  // 再生ボタン
                    playButton.onclick = function () {
                        // 再生処理をここに追加
                        playRadio(station.id, station.name, program.title);
                    };
                    programDiv.appendChild(playButton);

                    stationDiv.appendChild(programDiv);
                });

                liveRadioDiv.appendChild(stationDiv);
            });

            liveRadioDiv.style.display = 'block';
        })
        .catch(error => console.error('Error fetching radio stations:', error));
}

// ページ読み込み時に総再生時間を取得して表示
window.onload = function () {
    updateTotalPlayTime()
};

// 総再生時間の表示
function updateTotalPlayTime() {
    fetch('/get_total_play_time')
        .then(response => response.json())
        .then(data => {
            const playTimeValue = document.getElementById('play-time-value');
            playTimeValue.textContent = data.total_play_time;
        })
        .catch(error => console.error('Error fetching total play time:', error));
}

app.pyのコード(Pythonですべての機能を実行)

メインとなるラジオを再生したり、停止したり、再生時間を計算・保存するための処理です。
コードの中にあるrun_streamlink_path や PLAY_TIME_FILE のファイル名は適切なものに適宜変えてください。

from flask import Flask, render_template, jsonify, request
from get_radio_stations import get_live_radio_data
import os
import json 
import subprocess
import time
from datetime import datetime

app = Flask(__name__)

# グローバル変数で再生中のプロセスを保存
current_process = None
run_streamlink_path = "/home/○○/bin/streamlink" # streamlinkがインストールされているpath
STREAM_LINK = "https://radiko.jp/#!/live/" # radikoを再生するリンク
PLAY_TIME_FILE = "play_time.json"
# 再生状態を保持する変数
current_radio = None  # 現在再生中のラジオID
current_radio_name = "" # 現在再生中のラジオ名
start_time = None  # 再生開始時刻(UNIXタイムスタンプ)

# streams.json を返すルート
@app.route('/streams.json')
def streams():
    try:
        # JSON ファイルを読み込む
        with open('/streams.json', 'r') as file:
            data = json.load(file)  # JSON データを Python の辞書に変換
        
        # 読み込んだデータをそのまま返す
        return jsonify(data)
    
    except FileNotFoundError:
        # streams.json が見つからない場合
        return jsonify({"error": "streams.json file not found"}), 404
    except json.JSONDecodeError:
        # JSON ファイルが不正な形式の場合
        return jsonify({"error": "Invalid JSON format in streams.json"}), 400
    except Exception as e:
        # その他の例外をキャッチ
        return jsonify({"error": str(e)}), 500


# ラジオを停止する関数
def stop_radio():
    global current_process
    if current_process:
        current_process.terminate()  # プロセスを停止
        current_process = None  # 現在のプロセスをリセット


# ラジオを停止するルート
@app.route('/stop', methods=['GET'])
def stop_playing():
    stop_radio()
    
    global current_radio, current_radio_name, start_time

    # 再生時間を記録
    end_time = int(time.time())
    elapsed_time = end_time - int(start_time)
    save_play_time(current_radio, current_radio_name, int(start_time), end_time, elapsed_time)

    # 状態をリセット
    current_radio = None
    current_radio_name = ""
    start_time = None

    return jsonify({"status": "stopped"})


# ラジオ番組を再生するルート
@app.route('/play', methods=['GET'])
def play():
    global current_process, current_radio, start_time
    # 再生中のラジオがあれば停止
    stop_radio()

    # 再生するラジオのURLを取得
    station_id = request.args.get('station_id')
    radio_name = request.args.get('radio_name')
    # https://radiko.jp/#!/live/○○の形に整形
    stream_url = STREAM_LINK + station_id
    
        # 既に再生中のラジオがある場合は時間を記録
    if current_radio is not None and start_time is not None:
        elapsed_time = int(time.time() - start_time)
        save_play_time(current_radio, radio_name, start_time, int(time.time()), elapsed_time)

    # 新しいラジオを再生開始
    current_radio = station_id
    current_radio_name = radio_name
    start_time = time.time()
    
    
    if not stream_url:
        return jsonify({'error': 'No URL provided'}), 400
    
    try:
        # streamlink のコマンドを実行
        current_process = subprocess.Popen([run_streamlink_path, '-p', 'mpv', stream_url, 'best'])
        return jsonify({"status": "playing", "url": stream_url})
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/get_radio_stations')
def get_radio_stations():
    # 放送中のラジオデータを取得
    stations = get_live_radio_data()
    return jsonify({"status": "success", "stations": stations})


def save_play_time(radio_id, radio_name, start_time, end_time, play_duration):
    """
    再生時間をJSONファイルに保存する
    """
    try:
        with open(PLAY_TIME_FILE, "r") as file:
            play_time_data = json.load(file)
    except FileNotFoundError:
        play_time_data = {}

    # 日付を取得
    today = datetime.today().strftime('%Y-%m-%d')

    # 保存するデータを作成
    play_entry = {
        "radio_id": radio_id,
        "radio_name": radio_name,
        "start_time": start_time,
        "end_time": end_time,
        "play_duration": play_duration
    }

    # 日付をキーとしてデータを追加
    if today not in play_time_data:
        play_time_data[today] = []
    
    play_time_data[today].append(play_entry)

    # JSONファイルに保存
    with open(PLAY_TIME_FILE, "w") as file:
        json.dump(play_time_data, file, indent=4)


@app.route('/get_total_play_time')
def get_total_play_time():
    # play_time.jsonファイルを開く
    try:
        with open('play_time.json', 'r') as file:
            data = json.load(file)
        
        total_play_time = 0
        for date, records in data.items():
            for record in records:
                total_play_time += record['play_duration']
        
        # 合計時間を時間と分に変換
        hours = total_play_time // 3600
        minutes = (total_play_time % 3600) // 60
        seconds = total_play_time % 60

        total_play_time_str = f"{hours:02}:{minutes:02}:{seconds:02}"
        
        return json.dumps({'total_play_time': total_play_time_str})

    except FileNotFoundError:
        return json.dumps({'total_play_time': '00:00:00'})


@app.route('/')
def index():
    return render_template('index.html')

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)

get_radio_stations.py(現在放送中の番組取得処理)

radikoには現在放送中の番組を取得するためのAPIがありました。
これが便利だなと思ったので追加した機能になります。
下記のURLにアクセスするとXML形式でデータを取得することができます。
http://radiko.jp/v3/program/now/JP13.xml

ここではXML形式のデータを取得して、必要な値だけをstationsという変数に代入して画面側に表示するためのデータ整形処理になります。

import requests
from xml.etree import ElementTree
import datetime

current_program_api = 'http://radiko.jp/v3/program/now/JP13.xml'

def get_live_radio_data():
    # XMLデータを取得する
    response = requests.get(current_program_api)
    xml_data = response.text

    root = ElementTree.fromstring(xml_data)
    stations = []

    #  XMLデータから必要な値を取得
    for station in root.findall(".//station"):
        station_info = {
            "id": station.get("id"),
            "name": station.find("name").text,
            "programs": []
        }

        for prog in station.findall(".//prog"):
            prog_start = int(prog.get('ft'))
            prog_end = int(prog.get('to'))
            program_info = {
                "title": get_value(prog.find("title")),
                "url": get_value(prog.find("url"), ""),
                "pfm": get_value(prog.find("pfm")),
                "img": get_value(prog.find("img"), ""),
            }
            station_info["programs"].append(program_info)

        stations.append(station_info)

    return stations

def get_value(element, default="-"):
    # 値が None または "Null" の場合にデフォルト値を返す
    return element.text if element is not None and element.text != "Null" else default

streams.jsonのコード(お気に入りの番組まとめ)

streams.jsonにはお気に入りの番組をまとめるためのファイルです。
ここではJ-WAVE、Tokyofmのデータをまとめています。
追加していくと画面上に初期表示できるので好みの番組を追加していけます。

{
    "streams": [
        {
            "name": "J-WAVE",
            "id": "FMJ",
            "url": "https://radiko.jp/#!/live/FMJ"
        },
        {
            "name": "Tokyofm",
            "id": "FMT",
            "url": "https://radiko.jp/#!/live/FMT"
        }
    ]
}


実際に使ってみた感想・今後の活用シーン

実際に使ってみて、
100均スピーカーの質が良すぎない感じが気持ち良くラジオを聞けて味がある気がしました。

オリジナルラジオステーションなので好きな番組や総再生時間を見れるのも使ってみて気に入っています。
cssやHTMLファイルを変更すればお好みのデザインにカスタマイズできるのも楽しいと思います。

一方で使ってみてブラウザ上で操作するという面倒さがありました。

今ではスマホのブラウザ上でラズパイに直接アクセスできるとはいえ、
スマホでラジオ聞いたほうが便利なので本格利用するなら改善が必要そうです。

改善点・今後の展望

この機能をより汎用的に活用していくには以下のことができるといいかもしれないです!

  • 予約再生機能の追加
    朝の7時になったら自動で指定したラジオが流れる機能
  • 予約録音機能の追加
    好きな番組を録音してあとから視聴できる機能
  • AIスピーカーとの連携
    音声で再生・番組を切り替え・録音開始など

皆さんも是非チャレンジしてみてください!
カスタマイズしたアイデアがありましたら、シェアをお待ちしています😊

タイトルとURLをコピーしました