🎤

Rust製のターミナル上で動くシンプルなオーディオプレイヤー『Neiro』

に公開

Windowsで動作するシンプルなオーディオプレイヤー『Neiro』を作成しました。内部的にはcpalライブラリがサポートしていればどの環境でも動くはず。
https://github.com/c0b23092db/neiro

Neiro(ねゐろ)


タイトル通り、ターミナル上でオーディオファイルを再生できるコマンドです。日本語で『ねゐろ』です。
ファイラー機能はyazi経由での起動を想定しているので導入していません。yaziと合わせてお使いください。
ちなみにrodioを使った音声再生なので、tplayascii-termのようにffmpeg、openCV、vcpkgを入れなくてもビルドできます。

インストール方法

バイナリをダウンロード

https://github.com/c0b23092db/neiro/releases/

cargoからダウンロード

https://crates.io/crates/Neiro

cargo install Neiro

binstallが使えるならこちらも可能です。

cargo binstall Neiro

また、こちらの手法でもダウンロードできます。

cargo install --locked --git https://github.com/c0b23092db/neiro

開発の目的

Windows標準のファイラーを使っていると、どうしてもオーディオファイルが詰まったフォルダーを開くのが遅い問題がありました。特に大量の効果音が入っているフォルダーであればなおさら。また、Windows標準のメディアプレイヤーを開くのも少しのラグがある。
であれば、ターミナル上で音源を流すなら軽いのではないか、と考えました。しかし、WindowsにはLinuxやMacのようなターミナル上で音を流すコマンドが乏しい(ないと言ってもいい)問題があります。
なのでターミナル上で流すことができるシンプルなオーディオプレイヤーを作成しよう、というのが今回の目的です。
mpvを使えばいいというのは無視です。私はターミナル上で聞きたいのです。

使い方

ここでは軽い説明だけします。詳しい操作方法はGitHubのREADME.mdに載っているのでそちらを読んでください。・・・ここに書いてあるのとそこまで違いはありませんが。
sap.exeのヘルプを日本語訳したものです。

Simple Audio File Player in Terminal

Usage: sap.exe [オプション] <ファイル名>

Arguments:
  <ファイル名>   オーディオファイルのパス

オプション:
  -t, --timer <TIMER>    再生時間を指定する (0: 最後まで再生する) [デフォルト: 0]
  -v, --volume <VOLUME>  曲の音量を指定する [0-100] [デフォルト:50]
  -i, --interactive  インタラクティブモードで起動する。
  -h, --help         ヘルプを表示する。
  -V, --version      バージョンを表示する。

ファイルパスは必ず必要です。
起動オプションは同期再生モード(コマンド実行モード)とインタラクティブモード(TUIモード)の二つがあります。デフォルトで同期再生モードが起動し、オプションに-iを入れるとインタラクティブモードになります。

同期再生モード

sap audio.mp3

Ctrl + cで再生を止めます。オプションで音量や流す時間を調整します。以上です。

インタラクティブモード

sap -i audio.mp3

オプションの-iをつけて起動します。以下はTUIでの操作です。

  • Esc / q / Ctrl + c
    終了
  • スペースキー
    再開 / 一時停止
  • r
    リプレイ
  • s
    オーディオファイルの取り出し
  • ↑ ↓
    音量の変更。Shift同時押しで大きく変更。
  • ← →
    再生位置の変更。Shift同時押しで大きく変更。

技術スタック

  • オーディオ: rodio(クロスプラットフォームオーディオライブラリ)
  • UI: ratatui(ターミナルUI)
  • CLI: clap(コマンドライン引数解析)
  • 非同期: smol(軽量非同期ランタイム)
  • ターミナル制御: crossterm
  • エラー処理: anyhow
Cargo.toml
[dependencies]
clap = { version = "4.5.41", features = ["derive"] }
crossterm = "0.29.0"
ratatui = "0.29.0"
rodio = "0.21.1"
smol = "2.0.2"
anyhow = "1.0.98"

開発

技術を説明している記事が多かったので、めぼしいところだけですがどのように作ったかの説明を行いましょう。
ディレクトリの分割方法はこのようになっています。

scr
│  main.rs
├─core
│      mod.rs
│      player.rs
├─interactive
│      app.rs
│      interactiveplayer.rs
│      mod.rs
│      ratatui.rs
│      run.rs
└─terminal
        mod.rs
        terminalplayer.rs

terminal、interactiveで処理を分離しています。
共有で使える処理はcoreに入れて処理しています。現状はplayer.rsのみです。
バージョン0.2.0ではなくバージョン0.2.1のコードですがほとんど変わりないのでこれで説明します。

src

main.rs
fn main() -> ExitCode {
    let args = Cli::parse();
    match
        if args.mode_interactive { interactive::run::run_interactive_player(&args.file_name, args.volume) }
        else { terminal::terminalplayer::one_play(&args.file_name, args.volume, args.timer) }
    {
        Ok(_) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("{}",e);
            ExitCode::FAILURE
        }
    }
}

srcにあるmain.rsです。
最終的に返ってくる値はanyhowのResult<_>なのでmatchで処理を変えるようにしています。
こうすることによってコードを簡略的に分かりやすくできます。let shori = if ~~~とやってmatchをやるのもいいですね。

core

coreのplayer.rsにはプレイヤーとして共有で処理できる内容を入れています。

player.rs

initialize_soundplayer
core/player.rs
pub fn initialize_soundplayer() -> Result<(OutputStream,Sink)> {
    let mut stream_handle = OutputStreamBuilder::open_default_stream()
        .map_err(|e| anyhow!("Failed to initialize OutputStream in rodio:{}",e))?;
    stream_handle.log_on_drop(false);
    let sink = Sink::connect_new(&stream_handle.mixer());
    Ok((stream_handle,sink))
}

rodioのstream_handleとsinkを作成する関数です。
rodio 0.20.1ではなく0.21.1なので、よく見るlet (_stream, stream_handle) = OutputStream::try_default().unwrap();ではありません。
stream_handleとsinkを作成する関数のAPIが変わったくらいですかね。

append_one_track
core/player.rs
pub fn append_one_track(sink:&Sink,path:&Path) -> Result<Duration> {
    let file_data = File::open(path)
        .with_context(|| format!("Failed to open file:{}", path.display()))?;
    let decoder = Decoder::try_from(file_data)
        .map_err(|e| anyhow!("Failed to create decoder in rodio:{}",e))?;
    let duration = decoder.total_duration().unwrap_or(Duration::ZERO);
    sink.append(decoder);
    Ok(duration)
}

sinkにデコードしたファイルを挿入する関数です。イメージとしてはカセットを挿入する感じです。
ファイルをデコードできる場所はここしかないので、ここで合計時間を返すようにしています。

format_duration
core/player.rs
pub fn format_duration(duration:Duration) -> (u64,u64,u64,u32) {
    let hours = duration.as_secs() / 60 / 60;
    let minutes = duration.as_secs() / 60;
    let seconds = duration.as_secs() % 60;
    let millis = duration.subsec_millis();
    return (hours,minutes,seconds,millis);
}

Duration(時間)を(hours,minutes,seconds,millis)の四つに分解する関数です。TUIモードにおいて活躍してくれました。

check_and_get_path
core/player.rs
pub fn check_and_get_path(file_path:&str) -> Result<&Path> {
    let path = Path::new(file_path);
    if !path.is_file() {
        bail!("Audio file not found:{}", path.display());
    }
    Ok(path)
}

ファイルの存在を確認して&Pathを返す関数です。最初にこの処理を行って、早期リターンみたいに早く例外を返すようにします。

terminal

ファイル分割していると言っても、terminal/player.rsmod.rsしかありません。しかも関数一つのみです。

terminalplayer.rs

terminal/terminalplayer.rs
use crate::core::player;
use std::{thread, time::Duration};
use anyhow::Result;

pub fn one_play(file_name:&str, volume:u8, mut timer:u64) -> Result<()> {
    let file_path = player::check_and_get_path(file_name)?;
    let (_stream_handle, sink) = player::initialize_soundplayer()?;
    let duration = player::append_one_track(&sink,file_path)?;
    sink.set_volume(volume.clamp(0,200) as f32 / 100.0);
    sink.play();
    let (hours,minutes,seconds,_) = player::format_duration(duration);
    if timer == 0 || duration.as_secs() < timer {
        timer = duration.as_secs();
    }
    println!("{} | {}:{:02}:{:02} | {}:{:02}:{:02}",
                file_path.file_name().unwrap().to_string_lossy().into_owned(), hours, minutes, seconds,
                timer / 60 / 60, timer / 60 % 60, timer % 60);
    thread::sleep(Duration::from_secs(timer));
    Ok(())
}

驚きでしょうがこれしかありません。ループ機能とかつけても良かったのですが・・・。

    let file_path = player::check_and_get_path(file_name)?;
    let (_stream_handle, sink) = player::initialize_soundplayer()?;
    let duration = player::append_one_track(&sink,file_path)?

先ほどのcore/playerで定義した関数を使っています。こんな感じで気軽に使えるのはいいですね。

    sink.set_volume(volume.clamp(0,100) as f32 / 100.0);
    sink.play();

volume、仕様では0から100になっていますが、u8の最大である255は使えます。ただ、音割れ対策ですね。バージョン0.2.1では0から200まで使えるようにしています。

    sink.set_volume(volume.clamp(0,100) as f32 / 200.0);
    let (hours,minutes,seconds,_) = player::format_duration(duration);
    if timer == 0 || duration.as_secs() < timer {
        timer = duration.as_secs();
    }
    println!("{} | {}:{:02}:{:02} | {}:{:02}:{:02}",
                file_path.file_name().unwrap().to_string_lossy().into_owned(), hours, minutes, seconds,
                timer / 60 / 60, timer / 60 % 60, timer % 60);

ここでは時間を表記するコードを入れています。Durationを返すようにしたのがよかったですね。
file_path.file_name().unwrap().to_string_lossy().into_owned()でファイルの名前だけ取り出すようにできます。結構出てくる印象なので覚えて損はありません。

    thread::sleep(Duration::from_secs(timer));
    Ok(())

指定した時間スリープします。これがないと再生した瞬間プログラムが終了して再生が終了します。
元々デコードした時間分流すならsink.sleep_until_end()を使っていましたが、println!との兼ね合いでこのようなコードに。

interactive

インタラクティブモード、すなわちratatuiを使う内容ですね。
run.rsinteractiveplayer.rsapp.rsratatui.rsに分割されています。

run.rs

interactive/run.rs
pub fn run_interactive_player(file_path:&str, volume:u8) -> Result<()> {
    enable_raw_mode()?;
    let mut terminal = ratatui::init();
    let result = App::new().run(&mut terminal, file_path, volume);
    disable_raw_mode()?;
    ratatui::restore();
    result
}

ratatuiを使う上でのおまじないみたいなものです。
ratatui::restore();は画面を初期化する用の関数です。
それからenable_raw_mode()disable_raw_mode()はこんな感じだそうです。

enable_raw_mode()の効果
即座の入力処理: キーを押した瞬間に入力が処理され、Enterキーを待つ必要がなくなる
特殊キーの無効化: Ctrl+C(SIGINT)やCtrl+Z(SIGTSTP)などの制御文字が通常の文字として扱われる
エコーの無効化: タイプした文字が自動的に画面に表示されなくなる
行編集機能の無効化: バックスペースやカーソル移動などの行編集機能が使えなくなる
disable_raw_mode()の効果
行単位の入力処理: Enterキーを押すまで入力がバッファされる
制御文字の復活: Ctrl+CやCtrl+Zが本来の機能を取り戻す
エコーの復活: タイプした文字が画面に表示される
行編集機能の復活: バックスペース、カーソル移動などが使える

interactiveplayer.rs

interactive/interactiveplayer.rs
pub struct InteractivePlayer {
    pub sink: Sink,
    _stream_handle: OutputStream,
    pub file_path: String,
    pub file_name: String,
    pub volume: u8,
    pub duration_total_time: Duration,
    pub total_time: (u64, u64, u64, u32),
    pub duration_current_time: Duration,
    pub current_time: (u64, u64, u64, u32),
    pub time_update_sender: Sender<()>,
    pub time_update_receiver: Receiver<()>,
    pub time_update_stop: Arc<Mutex<bool>>,
}

サウンドプレイヤーのような使い心地に近づけました。
実際にはオーディオファイルは一つしか入りません(core/playerappend_one_trackで追加はできますが)が単純構造のサウンドプレイヤーならこれでいいです。

    pub time_update_sender: Sender<()>,
    pub time_update_receiver: Receiver<()>,
    pub time_update_stop: Arc<Mutex<bool>>,

ここはsmolの非同期処理に使う変数です。

interactive/interactiveplayer.rs
impl InteractivePlayer {
    pub fn new() -> Result<Self> {
        let (_stream_handle, sink) = player::initialize_soundplayer()?;
        let (time_update_sender, time_update_receiver) = unbounded();
        let time_update_stop = Arc::new(Mutex::new(false));
        Ok(Self {
            sink,
            _stream_handle,
            file_path: "".to_string(),
            file_name: "".to_string(),
            volume: 50,
            duration_total_time: Duration::ZERO,
            total_time: player::format_duration(Duration::ZERO),
            duration_current_time: Duration::ZERO,
            current_time: player::format_duration(Duration::ZERO),
            time_update_sender,
            time_update_receiver,
            time_update_stop,
        })
    }

デフォルトの値を返すようにするnew()です。
#[derive(Default)]が使えなかったのでこうなりました。なぜだ。

interactive/interactiveplayer.rs
    pub fn insert_and_play(&mut self, file_name: &str) -> Result<()> {
        self.insert(file_name)?;
        self.sink.play();
        Ok(())
    }

    pub fn insert(&mut self, file_name: &str) -> Result<()> {
        self.file_path = file_name.to_string();
        self.file_name = Path::new(file_name).file_name().unwrap().to_string_lossy().into_owned();
        let path = player::check_and_get_path(&self.file_path)?;
        self.duration_total_time = player::append_one_track(&self.sink, path)?;
        self.total_time = player::format_duration(self.duration_total_time);
        self.start_task_update_time();
        Ok(())
    }

    pub fn pause(&self) {
        self.sink.pause();
    }

    pub fn resume(&self) {
        self.sink.play();
    }

    pub fn switch_playback(&self) -> bool {
        if self.sink.is_paused() {
            self.resume();
            return true;
        } else {
            self.pause();
            return false;
        }
    }

    pub fn stop(&self) {
        self.sink.stop();
        if let Ok(mut stop_flag) = self.time_update_stop.lock() {
            *stop_flag = true;
        }
    }

    pub fn seek(&self, secs: Duration) -> Result<()> {
        self.sink
            .try_seek(secs)
            .map_err(|e| anyhow!("Failed to seek in rodio:{}", e))?;
        Ok(())
    }

    pub fn set_volume(&mut self, volume: u8) {
        self.volume = volume;
        self.sink.set_volume(volume as f32 / 100.0);
    }

    pub fn get_current_position(&mut self) {
        self.duration_current_time = self.sink.get_pos();
        self.current_time = player::format_duration(self.duration_current_time);
    }

   ...処理は続くよどこまでも...

}

基本的にsinkの処理をこちらで行うようにしているだけです。
めぼしいものと言えばこれくらいです。

interactive/interactiveplayer.rs
    pub fn stop(&self) {
        self.sink.stop();
        if let Ok(mut stop_flag) = self.time_update_stop.lock() {
            *stop_flag = true;
        }
    }
    pub fn seek(&self, secs: Duration) -> Result<()> {
        self.sink
            .try_seek(secs)
            .map_err(|e| anyhow!("Failed to seek in rodio:{}", e))?;
        Ok(())
    }

sinkの再生を止める処理と時間を飛ばすシーク処理です。
seekは5秒飛ばしのときに使っています。
問題としてこの処理は現状**エラーが起きない値が渡される前提``として扱われます。ここら辺はapp.rsでおいおい。こちらでエラー処理を行うのがいいですかね。

    pub fn get_current_position(&mut self) {
        self.duration_current_time = self.sink.get_pos();
        self.current_time = player::format_duration(self.duration_current_time);
    }

現在地の時間を取得する関数です。非同期処理ではこの関数を動かします。

interactive/interactiveplayer.rs
    fn start_task_update_time(&self) {
        let sender = self.time_update_sender.clone();
        let stop_update = self.time_update_stop.clone();
        smol::spawn(async move {
            loop {
                if let Ok(stop_flag) = stop_update.lock() {
                    if *stop_flag {
                        break;
                    }
                }
                if sender.send(()).await.is_err() {
                    break;
                }
                smol::Timer::after(Duration::from_millis(1)).await;
            }
        }).detach();
    }

    pub fn update_current_time(&mut self) {
        if let Ok(()) = self.time_update_receiver.try_recv() {
            self.get_current_position();
        }
    }

smolによる非同期ランタイムを使って時間を更新する部分です。とはいってもClaudeに出力してもらったので意味はあまり分かりません。
という事で動作の説明を出力してもらいました。

start_task_update_time関数の動作

  1. チャンネルとフラグの準備

    • time_update_sender(送信側)をクローン
    • time_update_stop(停止フラグ)をクローン
  2. 非同期タスクの生成

    • smol::spawnで新しい非同期タスクを作成
    • detach()でタスクを切り離し、バックグラウンドで実行継続
  3. タスク内のループ処理

    • 停止チェック: stop_updateのMutexをロックして停止フラグを確認
    • フラグがtrue: ループを抜けてタスク終了
    • シグナル送信: sender.send(()).awaitで受信側にシグナル送信
    • 送信エラー: 受信側が閉じられた場合はループを抜ける
    • 待機: smol::Timer::after(Duration::from_millis(1))で1ミリ秒待機

update_current_time関数の動作

  • 非ブロッキング受信: try_recv()でチャンネルからシグナルを受信
  • 成功時: get_current_position()を呼び出して現在位置を更新

この設計パターンは、1ミリ秒間隔で時間更新の要求を送信するバックグラウンドタスクと、メインスレッドで非ブロッキングに更新処理を行う仕組みを組み合わせています。

  • バックグラウンドタスクが定期的にシグナルを送信
  • メインループでupdate_current_timeを呼ぶことで、必要に応じて時間更新処理が実行される
  • try_recv()により、メインスレッドをブロックすることなく高頻度な更新が可能

このパターンは、リアルタイム処理やゲームループなどで時間に敏感な更新処理を実装する際によく使われる手法です。

とのことです。分かりやすいですね☆

ちなみに停止処理はstop()stop_flagtrueにしている感じです。シーケンス化するとこんな感じ。

1. stop()メソッド呼び出し

2. sink.stop() → 音楽再生停止

3. *stop_flag = true → フラグ設定

4. バックグラウンドタスクがフラグを検知

5. バックグラウンドタスクのループ終了

6. 完全停止完了
ratatui.rs

ratatuiを使うTUIの表示部分です。

interactive/ratatui.rs
pub fn draw(frame: &mut Frame, _app: &App, player: &InteractivePlayer) {
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints(vec![
            Constraint::Length(3), // タイトル・ファイル名
            Constraint::Percentage(100),
            Constraint::Length(1), // 再生バー
            Constraint::Length(1), // 再生ボタン
            Constraint::Length(3), // 音量バー
        ])
        .split(frame.area());

    // タイトル・ファイル名 //
    let file_name = Paragraph::new(&*player.file_name)
        .white().centered()
        .block(Block::bordered().title("Neiro").bold().blue());
    frame.render_widget(file_name, layout[0]);
    // 再生バー //
    let progress_percentage = if 0 < player.duration_total_time.as_secs() {
        ((player.duration_current_time.as_millis() as f64 / player.duration_total_time.as_millis() as f64) * 100.0) as u16
    } else { 0 };
    let progress_gauge = Gauge::default()
        .label(format!("{}:{:02}:{:02}:{:03} / {}:{:02}:{:02}:{:03}",
                        player.current_time.0,player.current_time.1,player.current_time.2,player.current_time.3,
                        player.total_time.0,player.total_time.1,player.total_time.2, player.total_time.3,
        ))
        .gauge_style(tailwind::BLUE.c500).percent(progress_percentage.clamp(0,100));
    frame.render_widget(progress_gauge, layout[2]);

        .centered();
    frame.render_widget(play_button, layout[3]);
}

長いですが仕組みは簡単です。

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints(vec![
            Constraint::Length(3), // タイトル・ファイル名
            Constraint::Percentage(100),
            Constraint::Length(1), // 再生バー
            Constraint::Length(1), // 再生ボタン
            Constraint::Length(3), // 音量バー
        ])
        .split(frame.area());

layoutを決める部分です。
https://ratatui.rs/concepts/layout/
ratatuiのチュートリアルは分かりやすいのでおすすめです。
https://ratatui.rs/

    // 実行ファイル名・ファイル名 //
    let file_name = Paragraph::new(&*player.file_name)
        .white().centered()
        .block(Block::bordered().title("Neiro").bold().blue());
    frame.render_widget(file_name, layout[0]);

タイトルを表示するコードです。
(&*player.file_name)がありますが、これはInteractivePlayerの&StringStringにするコードです。明示的な参照外し・・・のはずです。こうしないとcargo checkが通らなかったので合っているのかが。
いろいろ組み合わせた後、laytutの配列に入れるだけです。

    // 再生バー //
    let progress_percentage = if 0 < player.duration_total_time.as_secs() {
        ((player.duration_current_time.as_millis() as f64 / player.duration_total_time.as_millis() as f64) * 100.0) as u16
    } else { 0 };
    let progress_gauge = Gauge::default()
        .label(format!("{}:{:02}:{:02}:{:03} / {}:{:02}:{:02}:{:03}",
                        player.current_time.0,player.current_time.1,player.current_time.2,player.current_time.3,
                        player.total_time.0,player.total_time.1,player.total_time.2, player.total_time.3,
        ))
        .gauge_style(tailwind::BLUE.c500).percent(progress_percentage.clamp(0,100));
    frame.render_widget(progress_gauge, layout[2]);

ここでInteractivePlayerのsmol処理が生きてきます。InteractivePlayerが入った構造体が渡されるのでその中にあるcurrent_timeを引っ張ってくることで時間の更新を実現できます。
バーの表現はpercent(progress_percentage.clamp(0,100))で行っています。.clamp(0,100)はエラー防止です。
このようにブロックを作ってlayoutの格納してく処理になっています。

app.rs

interactive/app.rs
pub struct App {
    pub player: InteractivePlayer,
    // files_list:Vec<String>, // 実装予定
    exit: bool,
}

impl Default for App {
    fn default() -> Self {
        Self {
            player: InteractivePlayer::new().unwrap(),
            // files_list:Vec::new(),
            exit: false,
        }
    }
}

こちらはDefaultを実装できました。なぜだ。

ここからはimpl Appの処理です。

    pub fn run(&mut self, terminal: &mut DefaultTerminal, file_name: &str, volume: u8) -> Result<()> {
        self.player = InteractivePlayer::new()?;
        self.player.set_volume(volume);
        self.player.insert_and_play(&file_name)?;
        smol::block_on(async {
            while !self.exit {
                self.player.update_current_time();
                terminal.draw(|frame| draw(frame, &self, &self.player))?;
                if let Err(e) = self.handle_events_async().await {
                    return Err(e);
                }
                smol::Timer::after(Duration::from_millis(16)).await;
                if self.player.is_empty() && self.player.is_playing() {
                    self.exit = true;
                }
            }
            Ok(())
        })
    }

TUIモードのメインループ処理です。Appの中に入れています。
ここの処理はUIの更新とキー入力を非同期的に処理させたいのでsmol::block_on(async { ~~~ })を入れています。
self.player.update_current_time();は先ほどの時間更新ですね。これを行った後にterminal.draw(|frame| draw(frame, &self, &self.player))?;でTUIの更新を行います。
if let Err(e) = self.handle_events_async().await非同期的にキーの入力とイベントを
そしてsmol::Timer::after(Duration::from_millis(16)).await、つまり62.5Hz(1000 / 16)の感覚で処理を行っています。
処理のタスクをまとめるとこのようになっています。

タスク 1 : 時間更新:1ms間隔
タスク 2 : UI描画:16ms間隔
タスク 3 : イベント処理:非同期

非同期的な処理である時間の管理、UIの更新、キー入力の責任を分散することで修正がしやすくなったかと思います。
最後に再生終了したかのフラグチェックをして最初に戻ります。

                if self.player.is_empty() && self.player.is_playing() {
                    self.exit = true;
                }
interactive/app.rs
    async fn handle_events_async(&mut self) -> Result<()> {
        if event::poll(Duration::ZERO)? {
            match event::read()? {
                Event::Key(input) if input.kind == KeyEventKind::Press => {
                    self.handle_key_event(input)?;
                }
                Event::Mouse(_) => {}
                Event::Resize(_, _) => {}
                _ => {}
            }
        }
        Ok(())
    }

キー入力の受付です。なんてことはなくratatuiのテンプレートそのままです。

        if event::poll(Duration::ZERO)? {
            match event::read()? {

変わっている処理はcrosstermのキー入力判定をノンブロッキングにしたことですね。ノンブロッキングについてはこちらを。
https://zenn.dev/kamayla/articles/io-async-blocking
https://qiita.com/CRUD5th/items/4c9eec141a5f2826be00
入力がなければfalseが返ってくる構造です。

    fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
        match (key_event.modifiers, key_event.code) {

key_event: KeyEventでキー判定を貰って分離します。ratatuiのテンプレートではこのように処理しているようです。
その後に(_, KeyCode::Char(' '))(_, KeyCode::Char('r'))と処理をします。

            // 終了 //
            (_, KeyCode::Esc | KeyCode::Char('q'))
            | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => self.exit(),

ratatuiのチュートリアルで紹介されていた終了方法です。テンプレートとして覚えると便利かな。

            // 音量を変更 //
            (modifier, key @ (KeyCode::Up | KeyCode::Down)) => {
                let plus_or_minus = matches!(key, KeyCode::Up);
                let volume = if modifier == KeyModifiers::SHIFT { 10 } else { 5 };
                self.set_volume(volume, plus_or_minus)?
            },

音量を変更するコードです。
key @ (KeyCode::Up | KeyCode::Down)key @ ( ~ | ~ )という値を代入する構文です。地味に便利です。

            // 参考コード // 10秒進む //
            (KeyModifiers::SHIFT, KeyCode::Right) => self.seek(Duration::from_secs(10), true)?,
            // 参考コード // 5秒進む //
            (_, KeyCode::Right) => self.seek(Duration::from_secs(5), true)?,

参考までに通常の使い方です。

    fn set_volume(&mut self, volume:u8, plus_or_minus:bool) -> Result<()> {
        if plus_or_minus && 1.0 != self.player.get_volume(){
            self.player.set_volume((self.player.get_volume() * 100.0) as u8 + volume);
        }else if !plus_or_minus && 0.0 != self.player.get_volume(){
            self.player.set_volume((self.player.get_volume() * 100.0) as u8 - volume);
        }
        Ok(())
    }

    fn seek(&mut self, duration:Duration, plus_or_minus:bool) -> Result<()> {
        if plus_or_minus{
            if self.player.duration_total_time < self.player.duration_current_time + duration {
                self.player.seek(self.player.duration_total_time)?
            }else{
                self.player.seek(self.player.duration_current_time + duration)?
            }
        }else{
            if self.player.duration_current_time.as_secs() < 5 {
                self.player.seek(Duration::ZERO)?
            }else{
                self.player.seek(self.player.duration_current_time - duration)?
            }
        }
        Ok(())
    }

ここの処理はもう少しきれいにしたいです。
注意として、rodioのsinkは60秒の曲を120秒でも1000秒でも飛ばしてもエラーにならず最後の値ぴったりに飛ばしてくれますが、-10秒のようなマイナスの値が入るとオーバーフローしてErrorを返します。
そのため、適切に確認して値を渡しましょう。

裏話

なぜバージョンが0.2.0?

効果音を聞くためだけに作成した0.1.0は同期再生モードしかありませんでしたので、TUIを使えるようにするならバージョンを上げようとなりました。

名前問題

Simple Audio File Player(シンプルオーディオファイルプレイヤー)、Neiro(ねゐろ)、HuuRin(ふうりん)のいずれかを検討していました。
最終的にはNeiro(ねゐろ)になりましたが。
ちなみに実行ファイルがneiro.exenr.exeではなくsap.exeにしているかというと、単純にシンプルオーディオプレイヤー(Simple Audio Player)の頭文字から取っただけです。
ちなみにこの実行名はTUIがないバージョン0.1.0の時から変わっていません。

謎のビープ音

新しく買ったマウスが高速スクロールを搭載していたのでホイールで遊んでいました。
そうすると鳴るんですよね、ビープ音。
おそらく処理が間に合っていないんでしょうか、ともかく処理を飛ばして鳴らさないようにする処理を入れたほうがいいのかな。それとも、wavファイルでできた40分の音楽を流しているのがダメなのかな。

rodioについて

Cargo.tomlを確認すると分かりますが、7月10日頃に出たv0.21.1を使用しています。一部、APIが変わっていた部分もある(OutputStream::try_default().unwrap()からOutputStreamBuilder::open_default_stream())ので公式ドキュメントをくまなく読むことになりました。
ChatGPTに聞いても0.17.0や0.18.1のあたりしか出さないのはなぜだろか。0.20.0を出してほしかった。
symphoniaを使う案もありましたが、このくらいであればrodioが適任です。

smolについて

このクレートを採用する前はtokioを採用する予定でした。
しかし、Youtubeを視聴しているととある動画を見つけまして、このような設計思想があるならsmolが適切ではないかと考え今回のプロジェクトに導入したという事です。
https://www.youtube.com/watch?v=MvAaxQ2FrPM
ただ、さすがに学び始めだと分からない部分が多いのでClaudに出力してもらいましたが。

Claudeに流れを出力

分かりやすくするために流れを出力してもらいました。

## 全体的な実行フロー

### 1. エントリーポイント(main.rs)
- `clap`でコマンドライン引数を解析
- ファイルパス、タイマー、音量、インタラクティブモードフラグを取得
- モードに応じて処理を分岐:
  - `--interactive`フラグがある場合:`interactive::run::run_interactive_player()`
  - それ以外:`terminal::terminalplayer::one_play()`

### 2-A. シンプル再生モード(terminal/terminalplayer.rs)
**一度だけ再生して終了するモード**
1. ファイルパスの検証
2. オーディオストリームとシンクの初期化
3. オーディオファイルをデコードしてシンクに追加
4. 音量設定と再生開始
5. 指定時間または全体時間まで`thread::sleep()`で待機
6. 自動終了

### 2-B. インタラクティブモード(interactive/)
**UI付きの高機能プレイヤーモード**

#### 初期化フロー(run.rs)
1. ターミナルをrawモードに設定
2. ratatuiターミナル初期化
3. Appインスタンス作成・実行
4. 終了時にターミナル状態復元

#### メインループ(app.rs)
1. `InteractivePlayer`インスタンス作成
2. 音量設定とファイル読み込み・再生開始
3. 非同期メインループ:
   - 現在再生時間の更新(`update_current_time()`)
   - UI描画(ratatui経由)
   - キーボードイベント処理
   - 16ms間隔でループ
   - 再生終了またはユーザー操作で終了

#### プレイヤー機能(interactiveplayer.rs)
- **状態管理**: ファイル情報、再生時間、音量など
- **再生制御**: 再生/一時停止、シーク、音量調整、停止
- **時間更新**: 別タスクで1ms間隔で現在時刻を取得・チャンネル経由で通知
- **リアルタイム更新**: メインループから`update_current_time()`で最新状態を取得

#### UI描画(ratatui.rs)
5つのレイアウト領域に分割:
1. ファイル名表示(境界線付き)
2. 操作方法説明
3. 進捗バー(現在時刻/総時間表示)
4. 再生状態表示(▶/一時停止)
5. 音量ゲージ

#### キーボード操作(app.rs)
- `Esc/q/Ctrl+C`: 終了
- `Space`: 再生/一時停止切り替え
- `←/→` (Shift併用可): 再生位置調整(5秒/10秒)
- `↑/↓` (Shift併用可): 音量調整(5%/10%)
- `r`: リプレイ(先頭に戻る)
- `s`: 停止

## コア機能(core/player.rs)
両モードで共通利用される基本機能:
- ファイル存在確認
- rodioライブラリでのオーディオストリーム初期化
- デコーダー作成とシンクへの追加
- 時間フォーマット変換(Duration ⇔ 時:分:秒:ミリ秒)

最後に

Rustを使った開発はかなり安定してきた感覚です。あまり考えずとも繋がりが分かるようになってきたのは前進とも言えそうです。
他に作りたいものはありますが、Godotを使ったほうが早かったりするのでRustを使った開発は一旦休憩かな。

前に紹介したコマンドもcrates.ioに置いてみましたので、気になった方はぜひこちらも。
https://zenn.dev/yuhi_ili/articles/479b997e23e6eb
https://crates.io/crates/download_mover

追記ですが、入力が終わったらCtrl + sを押す癖があるせいで、入力途中で内容を更新してしまいますね・・・。
・・・なんかすでにダウンロードされてる!?

Discussion