kasys が 2026年01月27日17時06分55秒 に編集
コメント無し
本文の変更
## 概要・製作動機
SPRESENSEを用いて、携帯型デジタルオーディオプレーヤー **BubblePlayer** を制作しました。PC側で音源の準備(エンコード)とライブラリDB生成を行い、SPRESENSE側では無駄なファイルスキャンを避けて軽快にブラウズ・再生できるように設計し、 **「日常で使えるデジタルオーディオプレーヤー」** としての使い心地を目指しました。
SPRESENSEを用いて、携帯型デジタルオーディオプレーヤー **Bubble Player** を制作しました。PC側で音源の準備(エンコード)とライブラリDB生成を行い、SPRESENSE側では無駄なファイルスキャンを避けて軽快にブラウズ・再生できるように設計し、 **「日常で使えるデジタルオーディオプレーヤー」** としての使い心地を目指しました。
制作のきっかけは、昨年Xのタイムラインで過去に販売されていたいわゆる [ 香水瓶ウォークマン ](https://www.sony.jp/CorporateCruise/Press/200503/05-0308/?srsltid=AfmBOoozU3XK9Uo9dV-RNWj6OtMwgvoEL5vG9yE33x4h4HGrKTW0D1GA) を見かけ、その透明感あるデザインと“泡”が浮かぶような演出に強く惹かれたことです。そこで、この雰囲気をオマージュしつつ、ちゃんと使えるデジタルオーディオプレーヤー(DAP)をSPRESENSEで作ってみたいと考え、関連パーツを揃えて開発を開始しました。 本作品では、香水瓶ウォークマンの雰囲気(透明筐体・OLED表示・泡のスクリーンセーバー)を取り入れながら、 **(1) 曲数が増えても快適に動くデータ設計** と、**(2) 持ち歩き時に直感的に操作できるUI/操作系** を両立することを目標にしました。加えて、組込み機器としての合理性(軽量なデータ構造、低負荷な描画、少ない部品点数)も重視し、SPRESENSEを使った **“本格DAP”** として成立させることを狙っています。 ## 特徴 ### 曲数が増えても快適なDB設計 PC上でエンコードとDB生成を行うため、SPRESENSE側での全ファイルスキャンを避けられます。ID3タグなどのメタ情報もDB側に取り込み、タイトル/アーティスト等の表示に利用できます。実際に1500曲以上を入れても問題なく動作しています。 ### 多様な再生モード  プレイリスト機能に対応し、アーティスト/アルバム/年などでの再生にも対応しています。 ### PCで完結する書き込みツール(GUI)  Micro SDに書き込むためのデータ作成を、GUIベースのPythonツールで行えます。ライブラリの更新や管理を簡単にしました。 ### OLED表示(再生情報・バッテリー)  再生画面でタイトル/アーティスト/再生時間等を表示できます。バッテリー残量表示も搭載しています。 ### 泡スクリーンセーバー(加速度追従)  焼き付き防止のスクリーンセーバーとして泡アニメーションを実装しました。加速度センサで重力方向を推定し、泡が常に“上方向”に流れるため、持ち方で見え方が変化します。そのため、アニメーションの変化を楽しむことができます。 ### ジェスチャ操作(シェイク) 加速度センサを用いて、シェイクによる操作(例:2回シェイクで再生/一時停止)を実現しました。 ### 回転スイッチによる直感操作 上部の回転機構で前後曲などを操作できます。過去の「香水瓶ウォークマン」の操作をヒントに設計しました。 ### 大容量バッテリー+USB Type-C充電 1800mAhバッテリーを搭載し、USB Type-Cで充電できます。 ## システム構成 ### 概要  本機は **Spresense(メインボード+拡張ボード)** を中核に、表示・入力・電源を追加して携帯型プレーヤーとして成立させています。主な構成要素は **OLEDディスプレイ、6軸センサ(MPU-6050)、操作ボタン、リチウムイオン電池(3.7V 1800mAh)、充電・電源制御(LiPo Amigo Pro)** です。 電源は1800mAhのリチウムイオン電池を採用し、**LiPo Amigo Pro** によって **USB Type-Cでの充電、充電しながらの動作、電源スイッチによるON/OFF** を実現しています(携帯機としての運用性を重視しました)。 表示(OLED)とセンサ(MPU-6050)は **I2C(SDA/SCL)** 接続のため、同一バスに接続して配線を簡素化しています。操作ボタンはGPIO入力として接続し、まとめて **Arduinoシールド用ユニバーサル基板** 上に実装・配線しました。  音源とDBは **micro SDカード(32GB)** に格納し、SPRESENSE拡張ボードに接続しています。 ### 部品表 | 区分 | 部品名 | 型番 / 仕様 | 数量 | 備考(用途・補足) | | --- | ---------------- | ---------------------------: | -: | ------------------------------------------ | | メイン | SPRESENSE メインボード | SPRESENSE メインボード | 1 | 制御・再生処理 | | メイン | SPRESENSE 拡張ボード | SPRESENSE 拡張ボード | 1 | 3.3Vモードで使用 | | 表示 | OLEDディスプレイモジュール | 2.4インチ 128x64 I2C接続 SSD1309| 1 | 再生情報表示・スクリーンセーバー | | センサ | 6軸IMU(加速度/ジャイロ) | MPU-6050 モジュール(I2C) | 1 | シェイク操作・泡アニメの上方向推定 | | 入力 | タクトスイッチ | 汎用品(高さ2種類) | 6 | 操作ボタン。**うち2個を回転機構の左右入力として使用** | | 記憶 | microSDカード | 32GB | 1 | 楽曲+DB+プレイリスト格納 | | 電源 | LiPo充電・電源制御ボード | LiPo Amigo Pro(USB Type-C充電) | 1 | 充電・電源ON/OFF・電源系統の整理 | | 電源 | LiPoバッテリー | 3.7V / 1800mAh | 1 | 携帯用途の連続再生 | | 電源 | 電源スイッチ | 汎用スライドスイッチ | 1 | タクトスイッチでも可能だが、誤動作防止のためにスライドスイッチ採用 | | 配線 | ユニバーサル基板 | Arduinoシールド用ユニバーサル基板 | 1 | OLED/IMU/ボタン配線を集約 | | 配線 | ピンヘッダ/ソケット | 2.54mm ピッチ | 適量 |配線用| | 配線 | ジャンパ線 | 単線 | 適量 | 配線用 | | 配線 | ステレオミニジャック | 汎用品 | 1 | 延長配線用 | | 配線 | ステレオミニプラグ | 汎用品 | 1 | 延長配線用 | | 機構 | 3Dプリント筐体 | 4部品構成 | 1式 | Blender設計、接着剤不要の設計 、回転スイッチ機構兼用 | | 固定 | 基板固定材 | 両面テープ/フォーム材 等 | 適量 | **ネジ無し固定**のため | | 周辺 | USBケーブル | USB Type-C | 1 | 充電用 | | 周辺 | イヤホン | 3.5mm | 1 | デモ用 | ### 筐体 
++[3Dモデルのリンク](https://github.com/kasys1422/bubble-player/blob/main/3d_models/bubble_player_view.stl)++
  筐体はBlenderで設計し、3Dプリンタで出力しました。図のように **4つの部品**で構成され、接着剤を使わずに確実に組み付けできるように設計しています。特に、**上部の回転スイッチ機構が操作部であると同時に、筐体部品の結合(固定)を兼ねる**構造になっている点が特徴です。多色3Dプリンターで**OLED表示部に透明素材を使用**することで、筐体の堅牢性と表示を両立しています。  また、筐体には以下の図のようにパーツが配置されます。  ## ソフトウェア関連の設計内容 ### SPRESENSEにおける再生可能音源とソフトウェア SPRESNSEでは基本的にMP3とWAVファイルの再生しかサポートしていません。しかし、一般に普及している音源データとして最近はm4aやflacが主流となっているため変換する必要があります。また、SPRESENSEではMP3ファイルに含まれるID3メタデータ(タイトルやアーティスト名、アルバムなどの情報)を読めないという問題があります。これらは、デジタルオーディオプレーヤー(DAP)を作るうえでは致命的な問題となり得ます。 公式の[MP3プレイヤーサンプル](https://github.com/sonydevworld/spresense-arduino-compatible/blob/master/Arduino15/packages/SPRESENSE/hardware/spresense/1.0.0/libraries/Audio/examples/application/player_playlist/player_playlist.ino)では、プレイリストをCSVとして扱っています(TRACK_DB.CSV)。この場合、手書きでCSVを作るため、1000曲を超える規模のDAPとして運用することを考えると現実的な方法ではありません。また、SPRESENSEにファイルスキャンをさせる方法も考えられますが、これはCPU性能の問題から規模が大きくなると時間がかかりすぎます。 そこで、今回はあらかじめPCにソフトウェアを用意し、m4aやflacなどの非対応ファイルからのエンコード、メタデータの抽出、ファイルデータベースの作成、メタデータデータベースの作成などを行うことにしました。ソフトウェアはPythonで構築し、GUIはTkinter、エンコードやメタデータの抽出はffmpegを使用しています。DB構造やバイナリ形式、PythonコードはChatGPTと共に作成しました。また、自由に作れるプレイリストなども仕様として定義し、GUI上から作成できるようにしました。  ### SPRESENSE搭載プログラム SPRESENSE側はplatform.ioでAruduinoIDE環境としてプログラムしています。使っている外部ライブラリはGUIライブラリのU8g2のみです。起動後は次のような再生方法選択画面が出ます。再生方法には、プレイリスト再生、全楽曲再生、アルバム再生、アーティスト再生、同一年再生があります。  また、再生画面は以下のようになっています。タイトル、アーティスト、アルバム、再生時間、音量、バッテリーインジケータなどが表示されます。タイトル表示が収まりきらないときは自動スクロールで表示されるため、しっかりとタイトルを確認することが可能です。操作は、本体ボタンを押すか回転スイッチ、シェイク動作で可能です。本体ボタンでは、楽曲の一時停止/再生を操作したり、次の曲、前の曲の操作、音量の操作が可能です。回転スイッチは楽曲の変更、シェイク動作は2往復で楽曲の一時停止/再生、3往復で次の曲となっています。  OLEDの焼付き防止として以下のようなスクリーンセーバーも搭載しています。泡のアニメーションが常に地面に対して上方向に流れます。これは加速度センサーを使用して上方向を検出しています。泡に加えてタイトルなどを表示するモードも用意してあります。    ## おわりに 今回SPRESENSEを使用して実用的なDAPを作成することができました。自分が普通に使えるレベルの完成度になったため非常に満足しています。また、簡単に高品質なオーディオ再生ができるSPRESENSEの可能性を感じることができました。今後はシェイク動作にAI機能を使用したりしてさらなる改良を目指していきたいです。 ## ソースコード 今回、ソースコードはほとんどバイブコーディング(GPT-5.2-Codex)で作成しました。ソースコード構造を以下に示し、その後にソースコードや仕様を記述します。 ### ソースコード構造 ```markdown:プロジェクトのファイル構造 project-root/ ├─ platformio.ini ├─ src/ │ ├─ main.cpp │ └─ AppControllerIcons.cpp ├─ include/ │ ├─ Buttons.h │ ├─ OledUi.h │ ├─ app/ │ │ ├─ AppConfig.h │ │ ├─ AppController.h │ │ └─ AppState.h │ ├─ player/ │ │ ├─ IMusicPlayer.h │ │ └─ PlayerPaths.h │ └─ spdb/ │ ├─ IPlaylist.h │ ├─ ISpdb.h │ ├─ PlbV1Sdhci.h │ ├─ PlmV1Sdhci.h │ ├─ SpdbTypes.h │ ├─ SpdbV2Sdhci.h │ └─ Status.h └─ encode_helper/ └─ encode_helper.py ``` ### Pythonコード ※ Python 3.13 / Win11 で動作確認済み ※ 追加ライブラリ無しでも動きますが、mutagen を入れたほうが安定します。 ```python:encode_helper/encode_helper.py #!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import annotations import json import hashlib import os import platform import queue import re import struct import subprocess import threading import time import unicodedata import zlib from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import tkinter as tk from tkinter import ttk, filedialog, messagebox, simpledialog try: from mutagen import File as mutagen_File HAS_MUTAGEN = True except Exception: mutagen_File = None # type: ignore[assignment] HAS_MUTAGEN = False # ----------------------------- # Config # ----------------------------- AUDIO_EXTS_DEFAULT = [".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg", ".opus", ".wma"] UNKNOWN_ARTIST = "Unknown Artist" UNKNOWN_ALBUM = "Unknown Album" # DB constants (SPDB v2) DB_MAGIC = b"SPDB" DB_VERSION = 2 # Header: 92 bytes HEADER_FMT = "<4sHHIIIHHHH" + "I" * 16 HEADER_SIZE = struct.calcsize(HEADER_FMT) # Fixed record layouts # ArtistRec: name_off(u32), name_len(u16), album_link_count(u16), album_link_start(u32), reserved(u32) ARTIST_REC_FMT = "<IHHII" # 16 bytes # AlbumRec: name_off(u32), name_len(u16), artist_id(u16), year(u16), track_link_count(u16), track_link_start(u32), reserved(u32), reserved2(u32) ALBUM_REC_FMT = "<IHHHHIII" # 24 bytes # TrackRec (32 bytes): # title_off(u32), title_len(u16), # album_id(u16), artist_id(u16), track_no(u16), disc_no(u16), # duration_ms(u32), # path_off(u32), path_len(u16), # codec(u8), flags(u8), # reserved_u16(u16) -> SPDB v2: track_year (YYYY) # reserved2_u32(u32) TRACK_REC_FMT = "<IHHHHHIIHBBHI" # 32 bytes ARTIST_REC_SIZE = struct.calcsize(ARTIST_REC_FMT) ALBUM_REC_SIZE = struct.calcsize(ALBUM_REC_FMT) TRACK_REC_SIZE = struct.calcsize(TRACK_REC_FMT) # Playlist binary (.plb) PLB_MAGIC = b"PLB1" PLB_VERSION = 1 PLB_HEADER_FMT = "<4sHHI" # magic, version(u16), flags(u16), count(u32) PLB_HEADER_SIZE = struct.calcsize(PLB_HEADER_FMT) # Playlist metadata binary (.plm) PLM_MAGIC = b"PLM1" PLM_VERSION = 1 PLM_HEADER_FMT = "<4sHHIIIIII" # magic, version(u16), header_size(u16), flags(u32), count(u32), off_items(u32), off_string_pool(u32), string_size(u32), reserved(u32) PLM_HEADER_SIZE = struct.calcsize(PLM_HEADER_FMT) PLM_ITEM_FMT = "<IHIHII" # name_off(u32), name_len(u16), plb_off(u32), plb_len(u16), track_count(u32), reserved(u32) PLM_ITEM_SIZE = struct.calcsize(PLM_ITEM_FMT) # ----------------------------- # i18n # ----------------------------- I18N: Dict[str, Dict[str, str]] = { "ja": { "app_title": "SD MP3 Builder", "tab_library": "ライブラリ", "tab_playlists": "プレイリスト", "lang": "言語", "scan_root": "走査フォルダ(初期値: Music)", "browse": "参照…", "extensions": "拡張子", "scan": "走査", "encoding_options": "エンコード設定", "mode": "モード", "bitrate": "ビットレート (kbps)", "vbrq": "VBR 品質 (0..9)", "samplerate": "サンプルレート", "channels": "チャンネル", "enforce_cbr": "CBR固定(min/max/buf指定)", "strip_meta": "MP3からメタデータを削除(ID3/チャプタ等)", "write_xing": "Xing/LAMEヘッダを書き込む", "reencode_mp3": "MP3も再エンコード(品質統一)", "overwrite": "出力を上書き", "parallel": "並列数", "hash_paths": "出力パスをハッシュ化(日本語回避)", "output": "出力", "build_db": "DB生成 (DB/library.bin)", "db_only": "DBのみ更新(エンコードしない)", "write_glyphset": "glyphset.txt生成(後でフォント最小化に使用)", "encode_selected": "選択のみエンコード", "encode_all": "全てエンコード", "stop": "停止", "open_output": "出力フォルダを開く", "status": "状態", "rel_path": "相対パス", "src_path": "元ファイル", "playlist_help": "走査結果からプレイリストを作成できます。プレイリストは M3U8 / PLB(TrackIDリスト)/ PLM(名前メタ)で出力されます。", "playlist_add_help": "Libraryタブで曲を選択してから、ここで『追加』を押してください。", "playlist_go_library": "ライブラリへ移動", "playlist_select_here": "ここで曲を選択", "playlist_filter": "検索", "playlist_list": "プレイリスト一覧", "playlist_items": "プレイリスト内容", "new_playlist": "新規", "rename_playlist": "名前変更", "delete_playlist": "削除", "add_selected": "選択を追加", "remove_selected": "選択を削除", "move_up": "上へ", "move_down": "下へ", "export_playlists": "プレイリストを書き出し", "no_ffmpeg": "ffmpeg/ffprobe が見つかりません。PATHを通して再起動してください。", "scan_root_missing": "走査フォルダが見つかりません", "no_selection": "行が選択されていません", "scan_first": "先に走査してください", "playlist_name": "プレイリスト名", "invalid_name": "無効な名前です", "confirm_delete": "削除しますか?", }, "en": { "app_title": "SD MP3 Builder", "tab_library": "Library", "tab_playlists": "Playlists", "lang": "Language", "scan_root": "Scan root (default: Music)", "browse": "Browse…", "extensions": "Extensions", "scan": "Scan", "encoding_options": "Encoding Options", "mode": "Mode", "bitrate": "Bitrate (kbps)", "vbrq": "VBR q (0..9)", "samplerate": "Sample rate", "channels": "Channels", "enforce_cbr": "Enforce CBR (min/max/buf)", "strip_meta": "Strip MP3 metadata (ID3/chapters)", "write_xing": "Write Xing/LAME header", "reencode_mp3": "Re-encode MP3 too (unify)", "overwrite": "Overwrite output", "parallel": "Parallel jobs", "hash_paths": "Hash output paths (avoid non-ASCII)", "output": "Output", "build_db": "Build DB (DB/library.bin)", "db_only": "DB only (no encoding)", "write_glyphset": "Write glyphset.txt (for later font subset)", "encode_selected": "Encode Selected", "encode_all": "Encode All", "stop": "Stop", "open_output": "Open Output Folder", "status": "Status", "rel_path": "Relative Path", "src_path": "Source Path", "playlist_help": "Create playlists from scan results. Playlists are exported as M3U8 / PLB (TrackID list) / PLM (name metadata).", "playlist_add_help": "Select tracks in the Library tab, then click 'Add Selected' here.", "playlist_go_library": "Go to Library", "playlist_select_here": "Select tracks here", "playlist_filter": "Filter", "playlist_list": "Playlists", "playlist_items": "Playlist Items", "new_playlist": "New", "rename_playlist": "Rename", "delete_playlist": "Delete", "add_selected": "Add Selected", "remove_selected": "Remove Selected", "move_up": "Up", "move_down": "Down", "export_playlists": "Export Playlists", "no_ffmpeg": "ffmpeg/ffprobe not found. Please add them to PATH and restart.", "scan_root_missing": "Scan root not found", "no_selection": "No rows selected", "scan_first": "Please scan first", "playlist_name": "Playlist name", "invalid_name": "Invalid name", "confirm_delete": "Delete?", }, } def make_out_rel_path(src_rel_path: str, use_hashed_paths: bool) -> str: """Return SD root-relative output path (OutRelPath). - Default: keep original folder structure under MUSIC/. - Hashed: avoid non-ASCII by using metadata-based hashed filing. NOTE: For hashed mode, metadata is required to produce the final path. If metadata is not yet available, we return a "_pending" placeholder. """ rel_norm = (src_rel_path or "").replace("\\", "/") if not use_hashed_paths: return f"MUSIC/{Path(rel_norm).with_suffix('.mp3').as_posix()}" # Placeholder until we know artist/album/track_no etc. h = hashlib.sha1(rel_norm.encode("utf-8")).hexdigest() return f"MUSIC/_pending/{h[:16]}.mp3" def make_out_rel_path_from_meta(src_rel_path: str, meta: "TrackMeta") -> str: """Metadata-based hashed filing (ASCII only). Layout: MUSIC/a<artistHash8>/b<albumHash8>/d<disc>t<track>_<fileHash12>.mp3 """ src_rel_norm = (src_rel_path or "").replace("\\", "/") artist_key = norm_text(meta.artist) album_key = norm_text(meta.album) artist_h = hashlib.sha1(artist_key.encode("utf-8")).hexdigest()[:8] album_h = hashlib.sha1((artist_key + "\n" + album_key).encode("utf-8")).hexdigest()[:8] disc = int(meta.disc_no or 0) track = int(meta.track_no or 0) # Include src_rel_path to avoid collisions between same-title tracks. file_h = hashlib.sha1((src_rel_norm + "\n" + norm_text(meta.title)).encode("utf-8")).hexdigest()[:12] return f"MUSIC/a{artist_h}/b{album_h}/d{disc:02d}t{track:02d}_{file_h}.mp3" def safe_playlist_filename(name: str) -> str: # Keep Unicode but remove path separators and unsafe chars name = name.strip() name = name.replace("/", "_").replace("\\", "_").replace(":", "_") name = re.sub(r"\s+", " ", name) return name def make_plb_filename(name: str) -> str: """Return ASCII-only PLB filename derived from playlist name.""" key = norm_text(name) h = hashlib.sha1(key.encode("utf-8")).hexdigest()[:12] return f"pl_{h}.plb" # ----------------------------- # Data model # ----------------------------- @dataclass(frozen=True) class TrackMeta: out_rel_path: str title: str artist: str album: str track_no: int disc_no: int track_year: int # YYYY (0 if unknown) album_year: int # YYYY (0 if unknown) duration_ms: int codec: int flags: int @dataclass class TrackJob: src_path: Path rel_path: str status: str = "PENDING" error: str = "" out_rel: str = "" # ----------------------------- # Utility # ----------------------------- def default_music_dir() -> Path: return Path.home() / "Music" def _decode_subprocess_bytes(b: bytes) -> str: if not b: return "" # Try common encodings on Windows/JP setups; fall back to replacement for enc in ("utf-8", "utf-8-sig", "cp932", "mbcs"): try: return b.decode(enc) except Exception: pass return b.decode("utf-8", errors="replace") def is_ffmpeg_available() -> bool: try: subprocess.run(["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) subprocess.run(["ffprobe", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) return True except Exception: return False def open_folder(path: Path) -> None: p = str(path) if platform.system() == "Windows": os.startfile(p) # type: ignore[attr-defined] elif platform.system() == "Darwin": subprocess.run(["open", p], check=False) else: subprocess.run(["xdg-open", p], check=False) def _contains_cjk(s: str) -> bool: for ch in s: code = ord(ch) if 0x3040 <= code <= 0x30FF: return True # Hiragana/Katakana if 0x4E00 <= code <= 0x9FFF: return True # CJK Unified Ideographs return False def _contains_halfwidth_kana(s: str) -> bool: for ch in s: code = ord(ch) if 0xFF61 <= code <= 0xFF9F: return True return False def _looks_mojibake(s: str) -> bool: if not s: return False if "\ufffd" in s: return True return any(ch in s for ch in ("Ã", "Â", "â", "�")) def _fix_mojibake_cp932(s: str) -> str: if not s: return s # If already contains CJK, keep as-is. if _contains_cjk(s): return s # Heuristic: replacement char or common mojibake markers. if not _looks_mojibake(s): return s try: b = s.encode("latin-1", errors="ignore") if not b: return s fixed = b.decode("cp932", errors="replace") # Prefer fixed if it reduces replacement chars or adds CJK if fixed.count("\ufffd") <= s.count("\ufffd"): return fixed if _contains_cjk(fixed): return fixed return fixed except Exception: return s def norm_text(s: str) -> str: s = _fix_mojibake_cp932(s or "") if _contains_halfwidth_kana(s): s = unicodedata.normalize("NFKC", s) s = unicodedata.normalize("NFC", s) # Unify fullwidth spaces to halfwidth and collapse whitespace s = s.replace("\u3000", " ") s = re.sub(r"\s+", " ", s) return s.strip() def mutagen_tags_mp3(src: Path) -> Dict[str, str]: if not HAS_MUTAGEN or mutagen_File is None: return {} if src.suffix.lower() != ".mp3": return {} try: audio = mutagen_File(src) except Exception: return {} if not audio or not getattr(audio, "tags", None): return {} tags = audio.tags def _get_text(key: str) -> Optional[str]: v = tags.get(key) if v is None: return None if hasattr(v, "text") and v.text: return str(v.text[0]) try: return str(v) except Exception: return None out: Dict[str, str] = {} title = _get_text("TIT2") artist = _get_text("TPE1") album_artist = _get_text("TPE2") album = _get_text("TALB") track = _get_text("TRCK") disc = _get_text("TPOS") date = _get_text("TDRC") or _get_text("TYER") or _get_text("TDAT") if title: out["title"] = title if artist: out["artist"] = artist if album_artist: out["album_artist"] = album_artist out["albumartist"] = album_artist if album: out["album"] = album if track: out["track"] = track out["tracknumber"] = track if disc: out["disc"] = disc out["discnumber"] = disc if date: out["date"] = date out["year"] = date out["originaldate"] = date out["TDRC"] = date return out def parse_int_slash(value: Optional[str]) -> int: if not value: return 0 m = re.match(r"^\s*(\d+)", str(value)) return int(m.group(1)) if m else 0 def parse_year(value: Optional[str]) -> int: if not value: return 0 m = re.match(r"^\s*(\d{4})", str(value)) if not m: return 0 y = int(m.group(1)) return y if 1800 <= y <= 3000 else 0 def codec_code(ext: str, format_name: str, codec_name: str) -> int: ext = ext.lower() format_name = (format_name or "").lower() codec_name = (codec_name or "").lower() if ext == ".mp3" or codec_name == "mp3": return 1 if ext == ".wav" or "wav" in format_name or codec_name.startswith("pcm"): return 2 if ext == ".flac" or codec_name == "flac": return 3 if ext == ".m4a" or "mp4" in format_name or codec_name in ("aac", "alac"): return 4 if ext == ".ogg" or "ogg" in format_name: return 5 if ext == ".opus" or codec_name == "opus": return 6 if ext == ".aac" or codec_name == "aac": return 7 return 0 def ensure_unique_output_path(dst: Path) -> Path: if not dst.exists(): return dst stem = dst.stem suffix = dst.suffix parent = dst.parent for i in range(1, 10000): cand = parent / f"{stem}_{i}{suffix}" if not cand.exists(): return cand raise RuntimeError(f"Cannot find unique name for {dst}") # ----------------------------- # ffprobe # ----------------------------- def ffprobe_meta(src: Path) -> Dict[str, Any]: cmd = [ "ffprobe", "-v", "error", "-select_streams", "a:0", "-of", "json", "-show_entries", "format=duration,format_name:format_tags=title,artist,album,album_artist,albumartist,performer,track,tracknumber,disc,discnumber,date,year,originaldate,TDRC:stream=codec_name", str(src), ] p = subprocess.run(cmd, capture_output=True) # bytes if p.returncode != 0: stderr = _decode_subprocess_bytes(p.stderr) raise RuntimeError(stderr.strip() or "ffprobe failed") stdout = _decode_subprocess_bytes(p.stdout) return json.loads(stdout) def extract_trackmeta_for_index(src: Path, out_rel_path: str, infer_from_path: bool = True) -> TrackMeta: data = ffprobe_meta(src) fmt = (data.get("format") or {}) tags = (fmt.get("tags") or {}) format_name = fmt.get("format_name") or "" mut_tags = mutagen_tags_mp3(src) if mut_tags: # Prefer mutagen for MP3 when available. for k, v in mut_tags.items(): tags[k] = v streams = data.get("streams") or [] codec_name = "" if streams and isinstance(streams[0], dict): codec_name = streams[0].get("codec_name") or "" # duration duration_ms = 0 try: dur_s = fmt.get("duration") if dur_s is not None: duration_ms = int(float(dur_s) * 1000.0 + 0.5) except Exception: duration_ms = 0 def pick(keys: List[str]) -> Optional[str]: for k in keys: v = tags.get(k) if v is None: v = tags.get(k.upper()) if v is None: continue if isinstance(v, str) and v.strip(): return v.strip() if isinstance(v, (int, float)): return str(v) return None title = norm_text(pick(["title"]) or src.stem) # Prefer album artist to reduce artist list explosion artist = norm_text(pick(["album_artist", "albumartist", "artist", "performer"]) or UNKNOWN_ARTIST) album = norm_text(pick(["album"]) or UNKNOWN_ALBUM) track_no = parse_int_slash(pick(["track", "tracknumber"])) disc_no = parse_int_slash(pick(["disc", "discnumber"])) # Track year: prefer ORIGINALDATE / TDRC if present, else DATE/YEAR track_year = parse_year(pick(["originaldate", "TDRC", "date", "year"])) # Album year: if not explicitly available, reuse track year album_year = parse_year(pick(["date", "year"])) or track_year if infer_from_path: # Heuristic for missing tags. Only apply if unknown. if artist == UNKNOWN_ARTIST: try: if src.parent.parent.name: artist = norm_text(src.parent.parent.name) except Exception: pass if album == UNKNOWN_ALBUM: try: if src.parent.name: album = norm_text(src.parent.name) except Exception: pass ccode = codec_code(src.suffix, str(format_name), str(codec_name)) return TrackMeta( out_rel_path=out_rel_path, title=title, artist=artist, album=album, track_no=track_no, disc_no=disc_no, track_year=track_year, album_year=album_year, duration_ms=duration_ms, codec=ccode, flags=0, ) # ----------------------------- # SPDB builder # ----------------------------- class StringPool: def __init__(self) -> None: self.buf = bytearray() self.map: Dict[bytes, Tuple[int, int]] = {} def add(self, s: str) -> Tuple[int, int]: b = s.encode("utf-8") hit = self.map.get(b) if hit: return hit off = len(self.buf) self.buf.extend(b) self.map[b] = (off, len(b)) return off, len(b) def build_library_bin(metas: List[TrackMeta], crc32: bool = True) -> Tuple[bytes, Dict[str, Any], str]: """Build SPDB v2.""" artist_id: Dict[str, int] = {} # Album key includes album_year to distinguish same-name albums in different years album_id: Dict[Tuple[int, str, int], int] = {} artists: List[Dict[str, Any]] = [] albums: List[Dict[str, Any]] = [] tracks: List[Dict[str, Any]] = [] def get_artist(name: str) -> int: if name in artist_id: return artist_id[name] i = len(artists) if i > 0xFFFF: raise ValueError("artist_count exceeds u16") artist_id[name] = i artists.append({"name": name, "album_ids": []}) return i def get_album(aid: int, name: str, album_year: int) -> int: key = (aid, name, album_year) if key in album_id: return album_id[key] i = len(albums) if i > 0xFFFF: raise ValueError("album_count exceeds u16") album_id[key] = i albums.append({"name": name, "artist_id": aid, "year": album_year, "track_ids": []}) artists[aid]["album_ids"].append(i) return i # Build for m in metas: aid = get_artist(m.artist) alid = get_album(aid, m.album, m.album_year) tid = len(tracks) if tid > 0xFFFF: raise ValueError("track_count exceeds u16") tracks.append( { "title": m.title, "artist_id": aid, "album_id": alid, "track_no": m.track_no, "disc_no": m.disc_no, "duration_ms": m.duration_ms, "path": m.out_rel_path, "codec": m.codec, "flags": m.flags, "track_year": m.track_year, } ) albums[alid]["track_ids"].append(tid) # Global sort for artist/album lists (stable IDs after remap) if artists: artist_order = sorted(range(len(artists)), key=lambda i: artists[i]["name"].lower()) artist_id_map = {old: new for new, old in enumerate(artist_order)} new_artists = [{"name": artists[old]["name"], "album_ids": []} for old in artist_order] else: artist_id_map = {} new_artists = artists if albums: def _album_key(i: int) -> Tuple[str, int, str]: a = albums[i] old_aid = int(a.get("artist_id", 0)) artist_name = artists[old_aid]["name"].lower() if 0 <= old_aid < len(artists) else "" year = int(a.get("year", 0)) return (artist_name, 9999 if year == 0 else year, str(a.get("name", "")).lower()) album_order = sorted(range(len(albums)), key=_album_key) album_id_map = {old: new for new, old in enumerate(album_order)} new_albums: List[Dict[str, Any]] = [] for old in album_order: a = albums[old] new_aid = artist_id_map.get(int(a.get("artist_id", 0)), 0) new_albums.append( { "name": a.get("name", ""), "artist_id": new_aid, "year": int(a.get("year", 0)), "track_ids": list(a.get("track_ids", [])), } ) if 0 <= new_aid < len(new_artists): new_artists[new_aid]["album_ids"].append(len(new_albums) - 1) # Remap track references for t in tracks: t["artist_id"] = artist_id_map.get(int(t.get("artist_id", 0)), 0) t["album_id"] = album_id_map.get(int(t.get("album_id", 0)), 0) artists = new_artists albums = new_albums else: artists = new_artists # Sort ordering for a in artists: a["album_ids"].sort( key=lambda alid: ( 9999 if albums[alid]["year"] == 0 else albums[alid]["year"], albums[alid]["name"].lower(), ) ) for al in albums: al["track_ids"].sort( key=lambda tid: ( tracks[tid]["disc_no"] if tracks[tid]["disc_no"] > 0 else 9999, tracks[tid]["track_no"] if tracks[tid]["track_no"] > 0 else 9999, tracks[tid]["title"].lower(), tracks[tid]["path"].lower(), ) ) # Link arrays artist_album_links: List[int] = [] for a in artists: artist_album_links.extend(a["album_ids"]) album_track_links: List[int] = [] for al in albums: album_track_links.extend(al["track_ids"]) sp = StringPool() # Records artist_recs = bytearray() album_recs = bytearray() track_recs = bytearray() artist_album_cursor = 0 for a in artists: name_off, name_len = sp.add(a["name"]) cnt = len(a["album_ids"]) start = artist_album_cursor artist_album_cursor += cnt artist_recs.extend(struct.pack(ARTIST_REC_FMT, name_off, name_len, cnt, start, 0)) album_track_cursor = 0 for al in albums: name_off, name_len = sp.add(al["name"]) cnt = len(al["track_ids"]) start = album_track_cursor album_track_cursor += cnt album_recs.extend( struct.pack(ALBUM_REC_FMT, name_off, name_len, al["artist_id"], al["year"], cnt, start, 0, 0) ) for t in tracks: title_off, title_len = sp.add(t["title"]) path_off, path_len = sp.add(t["path"]) track_year_u16 = int(t.get("track_year", 0)) & 0xFFFF track_recs.extend( struct.pack( TRACK_REC_FMT, title_off, title_len, t["album_id"], t["artist_id"], t["track_no"], t["disc_no"], t["duration_ms"], path_off, path_len, t["codec"] & 0xFF, t["flags"] & 0xFF, track_year_u16, 0, ) ) assert len(artist_recs) == len(artists) * ARTIST_REC_SIZE assert len(album_recs) == len(albums) * ALBUM_REC_SIZE assert len(track_recs) == len(tracks) * TRACK_REC_SIZE total_artist_album = len(artist_album_links) total_album_track = len(album_track_links) artist_album_bytes = ( struct.pack("<" + "H" * total_artist_album, *artist_album_links) if total_artist_album else b"" ) album_track_bytes = ( struct.pack("<" + "H" * total_album_track, *album_track_links) if total_album_track else b"" ) string_pool_bytes = bytes(sp.buf) flags = 0 if crc32: flags |= 1 # Offsets off_artists = HEADER_SIZE off_albums = off_artists + len(artist_recs) off_tracks = off_albums + len(album_recs) off_artist_album_index = off_tracks + len(track_recs) off_album_track_index = off_artist_album_index + len(artist_album_bytes) off_string_pool = off_album_track_index + len(album_track_bytes) body = bytearray() body.extend(b"\x00" * HEADER_SIZE) body.extend(artist_recs) body.extend(album_recs) body.extend(track_recs) body.extend(artist_album_bytes) body.extend(album_track_bytes) body.extend(string_pool_bytes) crc_val = 0 if crc32: crc_val = zlib.crc32(body) & 0xFFFFFFFF db_size = len(body) + 4 else: db_size = len(body) build_epoch = int(time.time()) header_values = [ off_artists, off_albums, off_tracks, off_artist_album_index, off_album_track_index, off_string_pool, total_artist_album, total_album_track, ] + ([0] * 8) header = struct.pack( HEADER_FMT, DB_MAGIC, DB_VERSION, HEADER_SIZE, flags, build_epoch, db_size, len(artists), len(albums), len(tracks), 0, *header_values, ) body[0:HEADER_SIZE] = header if crc32: body.extend(struct.pack("<I", crc_val)) # glyphset glyph_chars = set() for a in artists: glyph_chars.update(a["name"]) for al in albums: glyph_chars.update(al["name"]) for t in tracks: glyph_chars.update(t["title"]) glyphset = "".join(sorted(glyph_chars)) stats = { "artists": len(artists), "albums": len(albums), "tracks": len(tracks), "string_pool_bytes": len(string_pool_bytes), "db_size_bytes": len(body), "crc32": bool(crc32), "version": DB_VERSION, } return bytes(body), stats, glyphset # ----------------------------- # Encoding # ----------------------------- @dataclass class EncodeOptions: bitrate_kbps: int = 192 mode: str = "CBR" # CBR/VBR vbr_q: int = 4 sample_rate: str = "AUTO" # AUTO/44100/48000... channels: str = "AUTO" # AUTO/1/2 enforce_cbr: bool = True strip_metadata: bool = True write_xing: bool = False reencode_mp3: bool = True overwrite: bool = True jobs: int = 1 db_only: bool = False def build_ffmpeg_cmd(src: Path, dst: Path, opt: EncodeOptions) -> List[str]: cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error"] cmd += ["-y"] if opt.overwrite else ["-n"] cmd += ["-i", str(src)] cmd += ["-vn", "-sn", "-dn"] if opt.strip_metadata: cmd += ["-map_metadata", "-1", "-map_chapters", "-1"] is_src_mp3 = src.suffix.lower() == ".mp3" if is_src_mp3 and not opt.reencode_mp3: cmd += ["-c:a", "copy"] else: cmd += ["-c:a", "libmp3lame"] if opt.mode.upper() == "VBR": cmd += ["-q:a", str(int(opt.vbr_q))] else: cmd += ["-b:a", f"{int(opt.bitrate_kbps)}k"] if opt.enforce_cbr: br = int(opt.bitrate_kbps) cmd += ["-minrate", f"{br}k", "-maxrate", f"{br}k", "-bufsize", f"{br*2}k"] if opt.sample_rate != "AUTO": cmd += ["-ar", str(opt.sample_rate)] if opt.channels != "AUTO": cmd += ["-ac", str(opt.channels)] # Best-effort: remove ID3 frames and Xing if supported by current ffmpeg if opt.strip_metadata: cmd += ["-write_id3v1", "0", "-write_id3v2", "0"] if not opt.write_xing: cmd += ["-write_xing", "0"] cmd += [str(dst)] return cmd def run_ffmpeg_with_fallback(cmd: List[str]) -> Tuple[int, str]: p = subprocess.run(cmd, capture_output=True) # bytes if p.returncode == 0: return 0, "" err = _decode_subprocess_bytes(p.stderr).strip() if "Unrecognized option" in err or "Option not found" in err: bad_keys = {"-write_id3v1", "-write_id3v2", "-write_xing"} cleaned: List[str] = [] i = 0 while i < len(cmd): tok = cmd[i] if tok in bad_keys: i += 2 continue cleaned.append(tok) i += 1 p2 = subprocess.run(cleaned, capture_output=True) if p2.returncode == 0: return 0, "" return p2.returncode, _decode_subprocess_bytes(p2.stderr).strip() return p.returncode, err # ----------------------------- # Cache # ----------------------------- def load_cache(cache_path: Path) -> Dict[str, Any]: if not cache_path.exists(): return {} try: return json.loads(cache_path.read_text(encoding="utf-8")) except Exception: return {} def save_cache(cache_path: Path, cache: Dict[str, Any]) -> None: cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8") def stat_sig(p: Path) -> Tuple[int, int]: st = p.stat() return (st.st_size, st.st_mtime_ns) # ----------------------------- # Playlist persistence + export # ----------------------------- def save_playlists_json(path: Path, playlists: Dict[str, List[str]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(playlists, ensure_ascii=False, indent=2), encoding="utf-8") def load_playlists_json(path: Path) -> Dict[str, List[str]]: if not path.exists(): return {} try: obj = json.loads(path.read_text(encoding="utf-8")) if isinstance(obj, dict): out: Dict[str, List[str]] = {} for k, v in obj.items(): if isinstance(k, str) and isinstance(v, list): out[k] = [str(x) for x in v] return out except Exception: return {} return {} def export_playlists_m3u8(out_dir: Path, playlists: Dict[str, List[str]]) -> None: out_dir.mkdir(parents=True, exist_ok=True) for name, items in playlists.items(): safe = safe_playlist_filename(name) if not safe: continue p = out_dir / f"{safe}.m3u8" # M3U8 with UTF-8 paths; MCU can parse line-by-line lines = ["#EXTM3U"] for rel in items: rel_norm = str(rel).replace("\\", "/") lines.append(rel_norm) p.write_text("\n".join(lines) + "\n", encoding="utf-8") def _parse_spdb_header(db: bytes) -> Tuple[int, int, int, int]: """Return (track_count, off_tracks, off_string_pool, flags).""" if len(db) < HEADER_SIZE: raise ValueError("library.bin too small") unpack = struct.unpack_from(HEADER_FMT, db, 0) magic = unpack[0] version = unpack[1] header_size = unpack[2] flags = unpack[3] db_size_bytes = unpack[5] track_count = unpack[8] if magic != DB_MAGIC: raise ValueError("SPDB magic mismatch") if version != DB_VERSION: raise ValueError(f"SPDB version mismatch: {version}") if header_size != HEADER_SIZE: raise ValueError("SPDB header_size mismatch") if db_size_bytes != len(db): # Accept if CRC32 enabled and file includes CRC; db_size_bytes should match. # If mismatch, treat as error to avoid wrong offsets. raise ValueError(f"SPDB size mismatch header={db_size_bytes} actual={len(db)}") # u32_table starts at index 10 u32_table = unpack[10:26] off_tracks = int(u32_table[2]) off_string_pool = int(u32_table[5]) return int(track_count), off_tracks, off_string_pool, int(flags) def build_path_to_trackid(db_path: Path) -> Dict[str, int]: """Parse SPDB v2 and build mapping: out_rel_path -> TrackID.""" raw = db_path.read_bytes() track_count, off_tracks, off_string_pool, _flags = _parse_spdb_header(raw) out: Dict[str, int] = {} for tid in range(track_count): base = off_tracks + tid * TRACK_REC_SIZE if base + TRACK_REC_SIZE > len(raw): raise ValueError("SPDB track table out of range") rec = struct.unpack_from(TRACK_REC_FMT, raw, base) path_off = int(rec[7]) path_len = int(rec[8]) s0 = off_string_pool + path_off s1 = s0 + path_len if s1 > len(raw): raise ValueError("SPDB string pool out of range") path = raw[s0:s1].decode("utf-8", errors="strict") # normalize slashes path = path.replace("\\", "/") out[path] = tid return out def export_playlists_plb(out_dir: Path, playlists: Dict[str, List[str]], db_path: Path, log_fn=None) -> List[Dict[str, Any]]: """Export playlists to .plb (TrackID list). Requires DB/library.bin. Returns a list of metadata entries: {name, plb_file, track_count, missing}. """ if not db_path.exists(): if log_fn: log_fn(f"[WARN] PLB export skipped: missing {db_path}") return [] path2id = build_path_to_trackid(db_path) out_dir.mkdir(parents=True, exist_ok=True) entries: List[Dict[str, Any]] = [] for name, items in playlists.items(): safe = safe_playlist_filename(name) if not safe: continue ids: List[int] = [] missing = 0 for rel in items: rel_norm = str(rel).replace("\\", "/") tid = path2id.get(rel_norm) if tid is None: missing += 1 continue ids.append(int(tid) & 0xFFFF) header = struct.pack(PLB_HEADER_FMT, PLB_MAGIC, PLB_VERSION, 0, len(ids)) body = struct.pack("<" + "H" * len(ids), *ids) if ids else b"" plb_file = make_plb_filename(name) out_path = out_dir / plb_file out_path.write_bytes(header + body) if log_fn: log_fn(f"[PLAYLIST] wrote {out_path} items={len(ids)} missing={missing} header={PLB_HEADER_SIZE}B") entries.append({ "name": name, "plb_file": plb_file, "track_count": len(ids), "missing": missing, }) return entries def export_playlists_plm(out_db_dir: Path, entries: List[Dict[str, Any]], log_fn=None) -> None: """Export playlist metadata to DB/playlists.bin (PLM v1).""" out_db_dir.mkdir(parents=True, exist_ok=True) out_path = out_db_dir / "playlists.bin" strings = bytearray() def _add_string(s: str) -> Tuple[int, int]: b = (s or "").encode("utf-8") off = len(strings) strings.extend(b) return off, len(b) items: List[Tuple[int, int, int, int, int]] = [] for e in entries: name = str(e.get("name", "")) plb_file = str(e.get("plb_file", "")) track_count = int(e.get("track_count", 0)) & 0xFFFFFFFF name_off, name_len = _add_string(name) plb_off, plb_len = _add_string(plb_file) items.append((name_off, name_len, plb_off, plb_len, track_count)) count = len(items) off_items = PLM_HEADER_SIZE off_string_pool = off_items + count * PLM_ITEM_SIZE string_size = len(strings) header = struct.pack( PLM_HEADER_FMT, PLM_MAGIC, PLM_VERSION, PLM_HEADER_SIZE, 0, count, off_items, off_string_pool, string_size, 0, ) buf = bytearray() buf.extend(header) for (name_off, name_len, plb_off, plb_len, track_count) in items: buf.extend(struct.pack(PLM_ITEM_FMT, name_off, name_len, plb_off, plb_len, track_count, 0)) buf.extend(strings) out_path.write_bytes(bytes(buf)) if log_fn: log_fn(f"[PLAYLIST] wrote {out_path} entries={count} header={PLM_HEADER_SIZE}B") # ----------------------------- # GUI # ----------------------------- class App(tk.Tk): def __init__(self) -> None: super().__init__() # state self.lang_var = tk.StringVar(value="ja") self.stop_event = threading.Event() self.ui_queue: "queue.Queue[Tuple[str, Any]]" = queue.Queue() self.script_dir = Path(__file__).resolve().parent self.out_root = self.script_dir self.out_music = self.out_root / "MUSIC" self.out_db = self.out_root / "DB" self.out_playlists = self.out_root / "PLAYLISTS" self.cache_path = self.out_root / "cache_ffprobe.json" self.playlists_json_path = self.out_db / "playlists.json" self.library_bin_path = self.out_db / "library.bin" self.cache = load_cache(self.cache_path) self.playlists: Dict[str, List[str]] = load_playlists_json(self.playlists_json_path) self.scan_root_var = tk.StringVar(value=str(default_music_dir())) self.exts_var = tk.StringVar(value=",".join(AUDIO_EXTS_DEFAULT)) self.jobs_var = tk.IntVar(value=1) self.mode_var = tk.StringVar(value="CBR") self.bitrate_var = tk.IntVar(value=192) self.vbrq_var = tk.IntVar(value=4) self.samplerate_var = tk.StringVar(value="AUTO") self.channels_var = tk.StringVar(value="AUTO") self.enforce_cbr_var = tk.BooleanVar(value=True) self.strip_meta_var = tk.BooleanVar(value=True) self.write_xing_var = tk.BooleanVar(value=False) self.reencode_mp3_var = tk.BooleanVar(value=True) self.overwrite_var = tk.BooleanVar(value=True) # If enabled, output paths under /MUSIC become ASCII-only hashed paths. # This helps environments where non-ASCII paths fail to open. self.hash_paths_var = tk.BooleanVar(value=True) self.build_db_var = tk.BooleanVar(value=True) self.write_glyphset_var = tk.BooleanVar(value=True) self.db_only_var = tk.BooleanVar(value=False) self.jobs: List[TrackJob] = [] # cache: out_rel_path -> display/meta self._outrel_meta_map: Optional[Dict[str, TrackMeta]] = None self._outrel_meta_map_sig: Optional[Tuple[int, bool]] = None self._set_title() if not is_ffmpeg_available(): messagebox.showerror("ffmpeg", self.tr("no_ffmpeg")) # UI containers self._root_frame = ttk.Frame(self) self._root_frame.pack(fill="both", expand=True) self._build_ui() self.after(100, self._poll_ui_queue) def tr(self, key: str) -> str: lang = self.lang_var.get() return I18N.get(lang, I18N["ja"]).get(key, key) def _set_title(self) -> None: self.title(self.tr("app_title")) def _rebuild_ui(self) -> None: # Preserve existing state; rebuild widgets with new language for child in self._root_frame.winfo_children(): child.destroy() self._set_title() self._build_ui() def _build_ui(self) -> None: self.geometry("1180x760") topbar = ttk.Frame(self._root_frame) topbar.pack(fill="x", padx=10, pady=8) ttk.Label(topbar, text=self.tr("lang")).pack(side="left") lang_box = ttk.Combobox(topbar, textvariable=self.lang_var, values=["ja", "en"], width=6, state="readonly") lang_box.pack(side="left", padx=6) lang_box.bind("<<ComboboxSelected>>", lambda _e: self._rebuild_ui()) ttk.Button(topbar, text=self.tr("open_output"), command=lambda: open_folder(self.out_root)).pack(side="right") # Notebook nb = ttk.Notebook(self._root_frame) nb.pack(fill="both", expand=True, padx=10, pady=6) tab_lib = ttk.Frame(nb) tab_pl = ttk.Frame(nb) nb.add(tab_lib, text=self.tr("tab_library")) nb.add(tab_pl, text=self.tr("tab_playlists")) self.nb = nb self.tab_lib = tab_lib self.tab_pl = tab_pl # -------- Library Tab -------- frm_scan = ttk.Frame(tab_lib) frm_scan.pack(fill="x", pady=4) ttk.Label(frm_scan, text=self.tr("scan_root")).grid(row=0, column=0, sticky="w") ttk.Entry(frm_scan, textvariable=self.scan_root_var, width=90).grid(row=0, column=1, padx=6, sticky="we") ttk.Button(frm_scan, text=self.tr("browse"), command=self._browse_root).grid(row=0, column=2, padx=4) ttk.Label(frm_scan, text=self.tr("extensions")).grid(row=1, column=0, sticky="w") ttk.Entry(frm_scan, textvariable=self.exts_var, width=90).grid(row=1, column=1, padx=6, sticky="we") ttk.Button(frm_scan, text=self.tr("scan"), command=self._scan).grid(row=1, column=2, padx=4) frm_scan.columnconfigure(1, weight=1) frm_opt = ttk.LabelFrame(tab_lib, text=self.tr("encoding_options")) frm_opt.pack(fill="x", pady=6) ttk.Label(frm_opt, text=self.tr("mode")).grid(row=0, column=0, sticky="w", padx=6, pady=4) ttk.Combobox(frm_opt, textvariable=self.mode_var, values=["CBR", "VBR"], width=8, state="readonly").grid( row=0, column=1, sticky="w" ) ttk.Label(frm_opt, text=self.tr("bitrate")).grid(row=0, column=2, sticky="w", padx=6) ttk.Spinbox(frm_opt, from_=64, to=320, increment=16, textvariable=self.bitrate_var, width=8).grid( row=0, column=3, sticky="w" ) ttk.Label(frm_opt, text=self.tr("vbrq")).grid(row=0, column=4, sticky="w", padx=6) ttk.Spinbox(frm_opt, from_=0, to=9, textvariable=self.vbrq_var, width=6).grid(row=0, column=5, sticky="w") ttk.Label(frm_opt, text=self.tr("samplerate")).grid(row=1, column=0, sticky="w", padx=6, pady=4) ttk.Combobox( frm_opt, textvariable=self.samplerate_var, values=["AUTO", "44100", "48000", "32000"], width=10, state="readonly" ).grid(row=1, column=1, sticky="w") ttk.Label(frm_opt, text=self.tr("channels")).grid(row=1, column=2, sticky="w", padx=6) ttk.Combobox(frm_opt, textvariable=self.channels_var, values=["AUTO", "1", "2"], width=8, state="readonly").grid( row=1, column=3, sticky="w" ) ttk.Checkbutton(frm_opt, text=self.tr("enforce_cbr"), variable=self.enforce_cbr_var).grid( row=1, column=4, columnspan=2, sticky="w", padx=6 ) ttk.Checkbutton(frm_opt, text=self.tr("strip_meta"), variable=self.strip_meta_var).grid( row=2, column=0, columnspan=3, sticky="w", padx=6, pady=4 ) ttk.Checkbutton(frm_opt, text=self.tr("write_xing"), variable=self.write_xing_var).grid( row=2, column=3, columnspan=2, sticky="w", padx=6 ) ttk.Checkbutton(frm_opt, text=self.tr("reencode_mp3"), variable=self.reencode_mp3_var).grid( row=2, column=5, sticky="w", padx=6 ) ttk.Checkbutton(frm_opt, text=self.tr("overwrite"), variable=self.overwrite_var).grid(row=2, column=6, sticky="w", padx=6) ttk.Label(frm_opt, text=self.tr("parallel")).grid(row=0, column=6, sticky="w", padx=6) ttk.Spinbox(frm_opt, from_=1, to=8, textvariable=self.jobs_var, width=6).grid(row=0, column=7, sticky="w") frm_out = ttk.LabelFrame(tab_lib, text=self.tr("output")) frm_out.pack(fill="x", pady=6) ttk.Label(frm_out, text=f"Output: {self.out_root}").grid(row=0, column=0, sticky="w", padx=6, pady=4) ttk.Checkbutton(frm_out, text=self.tr("hash_paths"), variable=self.hash_paths_var).grid(row=1, column=0, sticky="w", padx=6) ttk.Checkbutton(frm_out, text=self.tr("build_db"), variable=self.build_db_var).grid(row=2, column=0, sticky="w", padx=6) ttk.Checkbutton(frm_out, text=self.tr("write_glyphset"), variable=self.write_glyphset_var).grid(row=2, column=1, sticky="w", padx=6) ttk.Checkbutton(frm_out, text=self.tr("db_only"), variable=self.db_only_var).grid(row=3, column=0, sticky="w", padx=6) frm_btn = ttk.Frame(tab_lib) frm_btn.pack(fill="x", pady=6) ttk.Button(frm_btn, text=self.tr("encode_selected"), command=self._encode_selected).pack(side="left") ttk.Button(frm_btn, text=self.tr("encode_all"), command=self._encode_all).pack(side="left", padx=6) ttk.Button(frm_btn, text=self.tr("stop"), command=self._stop).pack(side="left", padx=6) # Tree frm_list = ttk.Frame(tab_lib) frm_list.pack(fill="both", expand=True, pady=6) columns = ("status", "rel", "src") self.tree = ttk.Treeview(frm_list, columns=columns, show="headings", selectmode="extended") self.tree.heading("status", text=self.tr("status")) self.tree.heading("rel", text=self.tr("rel_path")) self.tree.heading("src", text=self.tr("src_path")) self.tree.column("status", width=160, anchor="w") self.tree.column("rel", width=420, anchor="w") self.tree.column("src", width=520, anchor="w") ysb = ttk.Scrollbar(frm_list, orient="vertical", command=self.tree.yview) xsb = ttk.Scrollbar(frm_list, orient="horizontal", command=self.tree.xview) self.tree.configure(yscroll=ysb.set, xscroll=xsb.set) self.tree.grid(row=0, column=0, sticky="nsew") ysb.grid(row=0, column=1, sticky="ns") xsb.grid(row=1, column=0, sticky="ew") frm_list.columnconfigure(0, weight=1) frm_list.rowconfigure(0, weight=1) self.progress = ttk.Progressbar(tab_lib, mode="determinate") self.progress.pack(fill="x", pady=4) self.log = tk.Text(tab_lib, height=7, wrap="word") self.log.pack(fill="both", expand=False, pady=6) # -------- Playlists Tab -------- ttk.Label(tab_pl, text=self.tr("playlist_help")).pack(anchor="w", pady=4) pl_main = ttk.Frame(tab_pl) pl_main.pack(fill="both", expand=True) left = ttk.Frame(pl_main) mid = ttk.Frame(pl_main) right = ttk.Frame(pl_main) left.pack(side="left", fill="both") mid.pack(side="left", fill="both", padx=8) right.pack(side="left", fill="both", expand=True) ttk.Label(left, text=self.tr("playlist_list")).pack(anchor="w") left_list = ttk.Frame(left) left_list.pack(fill="both", expand=True) self.lb_playlists = tk.Listbox(left_list, height=16, exportselection=False) lb_pl_scroll = ttk.Scrollbar(left_list, orient="vertical", command=self.lb_playlists.yview) self.lb_playlists.configure(yscrollcommand=lb_pl_scroll.set) self.lb_playlists.pack(side="left", fill="both", expand=True) lb_pl_scroll.pack(side="right", fill="y") self.lb_playlists.bind("<<ListboxSelect>>", lambda _e: self._refresh_playlist_items()) btns_pl = ttk.Frame(left) btns_pl.pack(fill="x", pady=6) ttk.Button(btns_pl, text=self.tr("new_playlist"), command=self._playlist_new).pack(fill="x") ttk.Button(btns_pl, text=self.tr("rename_playlist"), command=self._playlist_rename).pack(fill="x", pady=3) ttk.Button(btns_pl, text=self.tr("delete_playlist"), command=self._playlist_delete).pack(fill="x") ttk.Label(mid, text=self.tr("playlist_items")).pack(anchor="w") mid_list = ttk.Frame(mid) mid_list.pack(fill="both", expand=True) self.lb_items = tk.Listbox(mid_list, height=16, selectmode="extended", exportselection=False) lb_it_scroll = ttk.Scrollbar(mid_list, orient="vertical", command=self.lb_items.yview) self.lb_items.configure(yscrollcommand=lb_it_scroll.set) self.lb_items.pack(side="left", fill="both", expand=True) lb_it_scroll.pack(side="right", fill="y") btns_it = ttk.Frame(mid) btns_it.pack(fill="x", pady=6) ttk.Button(btns_it, text=self.tr("remove_selected"), command=self._playlist_remove_items).pack(fill="x") ttk.Button(btns_it, text=self.tr("move_up"), command=lambda: self._playlist_move(-1)).pack(fill="x", pady=3) ttk.Button(btns_it, text=self.tr("move_down"), command=lambda: self._playlist_move(+1)).pack(fill="x") # Right side: add from library selection + export ttk.Label(right, text=self.tr("add_selected")).pack(anchor="w") ttk.Label(right, text=self.tr("playlist_add_help")).pack(anchor="w") ttk.Button(right, text=self.tr("playlist_go_library"), command=self._goto_library_tab).pack(anchor="w", pady=(4, 2)) ttk.Label(right, text=self.tr("playlist_select_here")).pack(anchor="w", pady=(8, 2)) frm_filter = ttk.Frame(right) frm_filter.pack(fill="x") ttk.Label(frm_filter, text=self.tr("playlist_filter")).pack(side="left") self.pl_filter_var = tk.StringVar(value="") ent_filter = ttk.Entry(frm_filter, textvariable=self.pl_filter_var) ent_filter.pack(side="left", fill="x", expand=True, padx=6) ent_filter.bind("<KeyRelease>", lambda _e: self._refresh_playlist_tree()) frm_pl_tree = ttk.Frame(right) frm_pl_tree.pack(fill="both", expand=True, pady=4) cols = ("title", "rel", "src") self.tree_pl = ttk.Treeview(frm_pl_tree, columns=cols, show="headings", selectmode="extended") self.tree_pl.heading("title", text="Title") self.tree_pl.heading("rel", text=self.tr("rel_path")) self.tree_pl.heading("src", text=self.tr("src_path")) self.tree_pl.column("title", width=240, anchor="w") self.tree_pl.column("rel", width=300, anchor="w") self.tree_pl.column("src", width=360, anchor="w") ysb_pl = ttk.Scrollbar(frm_pl_tree, orient="vertical", command=self.tree_pl.yview) xsb_pl = ttk.Scrollbar(frm_pl_tree, orient="horizontal", command=self.tree_pl.xview) self.tree_pl.configure(yscroll=ysb_pl.set, xscroll=xsb_pl.set) self.tree_pl.grid(row=0, column=0, sticky="nsew") ysb_pl.grid(row=0, column=1, sticky="ns") xsb_pl.grid(row=1, column=0, sticky="ew") frm_pl_tree.columnconfigure(0, weight=1) frm_pl_tree.rowconfigure(0, weight=1) ttk.Button(right, text=self.tr("add_selected"), command=lambda: self._playlist_add_selected_from_tree(self.tree_pl)).pack( anchor="w", pady=6 ) ttk.Button(right, text=self.tr("export_playlists"), command=self._export_playlists).pack(anchor="w") self._log_msg(f"[INIT] SPDB version={DB_VERSION}. Track year stored in TrackRec.reserved_u16.") self._refresh_playlists_listbox() def _build_outrel_meta_map(self) -> Dict[str, TrackMeta]: lib_sig: Optional[Tuple[int, int]] = None if self.library_bin_path.exists(): try: st = self.library_bin_path.stat() lib_sig = (int(st.st_size), int(st.st_mtime_ns)) except Exception: lib_sig = None sig = (len(self.cache), bool(self.hash_paths_var.get()), lib_sig) if self._outrel_meta_map is not None and self._outrel_meta_map_sig == sig: return self._outrel_meta_map out: Dict[str, TrackMeta] = {} for rel_key, ent in self.cache.items(): try: m = ent.get("meta", {}) meta = TrackMeta( out_rel_path="", title=str(m.get("title", Path(rel_key).stem)), artist=str(m.get("artist", UNKNOWN_ARTIST)), album=str(m.get("album", UNKNOWN_ALBUM)), track_no=int(m.get("track_no", 0)), disc_no=int(m.get("disc_no", 0)), track_year=int(m.get("track_year", 0)), album_year=int(m.get("album_year", 0)), duration_ms=int(m.get("duration_ms", 0)), codec=int(m.get("codec", 0)), flags=int(m.get("flags", 0)), ) out_rel_plain = make_out_rel_path(rel_key, False) out_rel_hash = make_out_rel_path_from_meta(rel_key, meta) out[out_rel_plain] = meta out[out_rel_hash] = meta except Exception: continue if self.library_bin_path.exists(): try: lib_map = self._build_outrel_meta_map_from_library_bin(self.library_bin_path) for k, v in lib_map.items(): if k not in out: out[k] = v except Exception: pass self._outrel_meta_map = out self._outrel_meta_map_sig = sig return out def _build_outrel_meta_map_from_library_bin(self, db_path: Path) -> Dict[str, TrackMeta]: raw = db_path.read_bytes() if len(raw) < HEADER_SIZE: return {} unpack = struct.unpack_from(HEADER_FMT, raw, 0) magic = unpack[0] version = unpack[1] header_size = unpack[2] db_size_bytes = unpack[5] artist_count = int(unpack[6]) album_count = int(unpack[7]) track_count = int(unpack[8]) if magic != DB_MAGIC or version != DB_VERSION or header_size != HEADER_SIZE: return {} if db_size_bytes != len(raw): return {} u32_table = unpack[10:26] off_artists = int(u32_table[0]) off_albums = int(u32_table[1]) off_tracks = int(u32_table[2]) off_string_pool = int(u32_table[5]) def _read_str(off: int, length: int) -> str: if length <= 0: return "" s0 = off_string_pool + off s1 = s0 + length if s0 < 0 or s1 > len(raw): return "" return raw[s0:s1].decode("utf-8", errors="replace") artist_names: List[str] = [UNKNOWN_ARTIST] * max(artist_count, 0) for i in range(artist_count): base = off_artists + i * ARTIST_REC_SIZE if base + ARTIST_REC_SIZE > len(raw): break rec = struct.unpack_from(ARTIST_REC_FMT, raw, base) name_off = int(rec[0]) name_len = int(rec[1]) artist_names[i] = _read_str(name_off, name_len) or UNKNOWN_ARTIST album_names: List[str] = [UNKNOWN_ALBUM] * max(album_count, 0) album_artist_ids: List[int] = [0] * max(album_count, 0) for i in range(album_count): base = off_albums + i * ALBUM_REC_SIZE if base + ALBUM_REC_SIZE > len(raw): break rec = struct.unpack_from(ALBUM_REC_FMT, raw, base) name_off = int(rec[0]) name_len = int(rec[1]) artist_id = int(rec[2]) album_names[i] = _read_str(name_off, name_len) or UNKNOWN_ALBUM album_artist_ids[i] = artist_id out: Dict[str, TrackMeta] = {} for i in range(track_count): base = off_tracks + i * TRACK_REC_SIZE if base + TRACK_REC_SIZE > len(raw): break rec = struct.unpack_from(TRACK_REC_FMT, raw, base) title_off = int(rec[0]) title_len = int(rec[1]) album_id = int(rec[2]) artist_id = int(rec[3]) track_no = int(rec[4]) disc_no = int(rec[5]) duration_ms = int(rec[6]) path_off = int(rec[7]) path_len = int(rec[8]) codec = int(rec[9]) flags = int(rec[10]) track_year = int(rec[11]) title = _read_str(title_off, title_len) or f"Track {i}" path = _read_str(path_off, path_len) if not path: continue artist = UNKNOWN_ARTIST if 0 <= artist_id < len(artist_names): artist = artist_names[artist_id] or UNKNOWN_ARTIST album = UNKNOWN_ALBUM if 0 <= album_id < len(album_names): album = album_names[album_id] or UNKNOWN_ALBUM if artist_id == 0 and 0 <= album_artist_ids[album_id] < len(artist_names): artist = artist_names[album_artist_ids[album_id]] or artist meta = TrackMeta( out_rel_path=path, title=title, artist=artist, album=album, track_no=track_no, disc_no=disc_no, track_year=track_year, album_year=0, duration_ms=duration_ms, codec=codec, flags=flags, ) out[path.replace("\\", "/")] = meta return out def _display_label_for_out_rel(self, out_rel: str) -> str: out_rel_norm = (out_rel or "").replace("\\", "/") meta_map = self._build_outrel_meta_map() meta = meta_map.get(out_rel_norm) if not meta: return out_rel_norm title = meta.title or Path(out_rel_norm).stem artist = meta.artist or UNKNOWN_ARTIST album = meta.album or UNKNOWN_ALBUM return f"{title} / {artist} - {album}" # -------- Logging -------- def _log_msg(self, msg: str) -> None: self.log.insert("end", msg + "\n") self.log.see("end") # -------- Scan -------- def _browse_root(self) -> None: p = filedialog.askdirectory(initialdir=self.scan_root_var.get() or str(default_music_dir())) if p: self.scan_root_var.set(p) def _scan(self) -> None: root = Path(self.scan_root_var.get()).expanduser() if not root.exists(): messagebox.showerror("Scan", f"{self.tr('scan_root_missing')}:\n{root}") return exts: List[str] = [] for tok in self.exts_var.get().split(","): t = tok.strip().lower() if not t: continue if not t.startswith("."): t = "." + t exts.append(t) if not exts: messagebox.showerror("Scan", "Extensions list is empty") return self.stop_event.clear() self.jobs.clear() for item in self.tree.get_children(): self.tree.delete(item) self._log_msg(f"[SCAN] root={root}") t = threading.Thread(target=self._scan_worker, args=(root, set(exts)), daemon=True) t.start() def _scan_worker(self, root: Path, exts: set[str]) -> None: count = 0 for p in root.rglob("*"): if self.stop_event.is_set(): break if not p.is_file(): continue if p.suffix.lower() not in exts: continue rel = p.relative_to(root).as_posix() job = TrackJob(src_path=p, rel_path=rel) # Precompute output path (hash/non-hash) and mark existence try: rel_key = rel if bool(self.hash_paths_var.get()): sig = stat_sig(p) cache_ent = self.cache.get(rel_key) if cache_ent and tuple(cache_ent.get("sig", ())) == sig: m = cache_ent["meta"] meta = TrackMeta( out_rel_path="", title=m.get("title", p.stem), artist=m.get("artist", UNKNOWN_ARTIST), album=m.get("album", UNKNOWN_ALBUM), track_no=int(m.get("track_no", 0)), disc_no=int(m.get("disc_no", 0)), track_year=int(m.get("track_year", 0)), album_year=int(m.get("album_year", 0)), duration_ms=int(m.get("duration_ms", 0)), codec=int(m.get("codec", 0)), flags=int(m.get("flags", 0)), ) else: meta = extract_trackmeta_for_index(p, out_rel_path="MUSIC/_tmp.mp3", infer_from_path=True) self.cache[rel_key] = { "sig": list(sig), "meta": { "title": meta.title, "artist": meta.artist, "album": meta.album, "track_no": meta.track_no, "disc_no": meta.disc_no, "track_year": meta.track_year, "album_year": meta.album_year, "duration_ms": meta.duration_ms, "codec": meta.codec, "flags": meta.flags, }, } out_rel = make_out_rel_path_from_meta(rel, meta) else: out_rel = make_out_rel_path(rel, False) job.out_rel = out_rel out_abs = self.out_root / out_rel job.status = "EXISTS" if out_abs.exists() else "NEW" except Exception: job.out_rel = make_out_rel_path(rel, False) job.status = "NEW" self.jobs.append(job) count += 1 if count % 200 == 0: self.ui_queue.put(("scan_progress", count)) self.ui_queue.put(("scan_done", count)) # -------- Encode -------- def _stop(self) -> None: self.stop_event.set() self._log_msg("[STOP] requested") def _encode_selected(self) -> None: sel = self.tree.selection() if not sel: messagebox.showinfo("Encode", self.tr("no_selection")) return idxs: List[int] = [] for iid in sel: tags = self.tree.item(iid, "tags") if tags and tags[0].isdigit(): idxs.append(int(tags[0])) if not idxs: messagebox.showerror("Encode", "Selection mapping failed") return self._start_encode(idxs) def _encode_all(self) -> None: if not self.jobs: messagebox.showinfo("Encode", self.tr("scan_first")) return self._start_encode(list(range(len(self.jobs)))) def _start_encode(self, indices: List[int]) -> None: if not self.jobs: messagebox.showinfo("Encode", self.tr("scan_first")) return opt = EncodeOptions( bitrate_kbps=int(self.bitrate_var.get()), mode=self.mode_var.get().upper(), vbr_q=int(self.vbrq_var.get()), sample_rate=self.samplerate_var.get(), channels=self.channels_var.get(), enforce_cbr=bool(self.enforce_cbr_var.get()), strip_metadata=bool(self.strip_meta_var.get()), write_xing=bool(self.write_xing_var.get()), reencode_mp3=bool(self.reencode_mp3_var.get()), overwrite=bool(self.overwrite_var.get()), jobs=max(1, int(self.jobs_var.get())), db_only=bool(self.db_only_var.get()), ) self.stop_event.clear() self.out_music.mkdir(parents=True, exist_ok=True) self.out_db.mkdir(parents=True, exist_ok=True) self.out_playlists.mkdir(parents=True, exist_ok=True) self._log_msg(f"[ENCODE] start selected={len(indices)} output={self.out_root}") self._log_msg( f"[ENCODE] mode={opt.mode} bitrate={opt.bitrate_kbps}k vbr_q={opt.vbr_q} sr={opt.sample_rate} ch={opt.channels} jobs={opt.jobs}" ) self._log_msg( f"[ENCODE] strip_meta={opt.strip_metadata} write_xing={opt.write_xing} reencode_mp3={opt.reencode_mp3} overwrite={opt.overwrite}" ) if opt.db_only: self._log_msg("[DB] db_only enabled: skip encoding, rebuild DB from tags") t = threading.Thread(target=self._encode_worker, args=(indices, opt), daemon=True) t.start() def _encode_worker(self, indices: List[int], opt: EncodeOptions) -> None: root = Path(self.scan_root_var.get()).expanduser().resolve() total = len(indices) done = 0 metas_for_db: List[TrackMeta] = [] q_idx: "queue.Queue[int]" = queue.Queue() for idx in indices: q_idx.put(idx) lock = threading.Lock() def worker() -> None: nonlocal done while not self.stop_event.is_set(): try: idx = q_idx.get_nowait() except queue.Empty: return job = self.jobs[idx] src = job.src_path rel = job.rel_path try: rel_key = src.relative_to(root).as_posix() sig = stat_sig(src) cache_ent = self.cache.get(rel_key) # 1) Decide metadata (from cache or ffprobe) meta: TrackMeta if cache_ent and tuple(cache_ent.get("sig", ())) == sig: m = cache_ent["meta"] meta = TrackMeta( out_rel_path="", title=m.get("title", src.stem), artist=m.get("artist", UNKNOWN_ARTIST), album=m.get("album", UNKNOWN_ALBUM), track_no=int(m.get("track_no", 0)), disc_no=int(m.get("disc_no", 0)), track_year=int(m.get("track_year", 0)), album_year=int(m.get("album_year", 0)), duration_ms=int(m.get("duration_ms", 0)), codec=int(m.get("codec", 0)), flags=int(m.get("flags", 0)), ) else: # out_rel_path is decided later; pass a placeholder for now. meta = extract_trackmeta_for_index(src, out_rel_path="MUSIC/_tmp.mp3", infer_from_path=True) self.cache[rel_key] = { "sig": list(sig), "meta": { "title": meta.title, "artist": meta.artist, "album": meta.album, "track_no": meta.track_no, "disc_no": meta.disc_no, "track_year": meta.track_year, "album_year": meta.album_year, "duration_ms": meta.duration_ms, "codec": meta.codec, "flags": meta.flags, }, } # 2) Decide final OutRelPath if bool(self.hash_paths_var.get()): out_rel = make_out_rel_path_from_meta(rel, meta) else: out_rel = make_out_rel_path(rel, False) # Rewrite meta with final path (used by DB builder) meta = TrackMeta( out_rel_path=out_rel, title=meta.title, artist=meta.artist, album=meta.album, track_no=meta.track_no, disc_no=meta.disc_no, track_year=meta.track_year, album_year=meta.album_year, duration_ms=meta.duration_ms, codec=meta.codec, flags=meta.flags, ) if not opt.db_only: out_abs = self.out_root / out_rel out_abs.parent.mkdir(parents=True, exist_ok=True) if out_abs.exists() and not opt.overwrite: out_abs = ensure_unique_output_path(out_abs) cmd = build_ffmpeg_cmd(src, out_abs, opt) rc, err = run_ffmpeg_with_fallback(cmd) if rc != 0: raise RuntimeError(err or f"ffmpeg failed (code {rc})") with lock: metas_for_db.append(meta) job.status = "DB" if opt.db_only else "DONE" job.error = "" self.ui_queue.put(("row_update", idx)) except Exception as e: job.status = "ERROR" job.error = str(e) self.ui_queue.put(("row_update", idx)) self.ui_queue.put(("log", f"[ERROR] {rel}: {e}")) finally: with lock: done += 1 self.ui_queue.put(("encode_progress", (done, total))) q_idx.task_done() workers: List[threading.Thread] = [] for _ in range(max(1, opt.jobs)): th = threading.Thread(target=worker, daemon=True) th.start() workers.append(th) for th in workers: th.join() # Save cache try: save_cache(self.cache_path, self.cache) except Exception as e: self.ui_queue.put(("log", f"[WARN] cache save failed: {e}")) # Build DB if self.build_db_var.get() and metas_for_db and not self.stop_event.is_set(): try: db_bytes, stats, glyphset = build_library_bin(metas_for_db, crc32=True) out_lib = self.out_db / "library.bin" out_lib.write_bytes(db_bytes) self.ui_queue.put( ( "log", f"[DB] wrote {out_lib} size={stats['db_size_bytes']} artists={stats['artists']} albums={stats['albums']} tracks={stats['tracks']} ver={stats['version']}", ) ) if self.write_glyphset_var.get(): (self.out_db / "glyphset.txt").write_text(glyphset, encoding="utf-8") self.ui_queue.put(("log", f"[DB] wrote {self.out_db / 'glyphset.txt'} unique_chars={len(glyphset)}")) except Exception as e: self.ui_queue.put(("log", f"[ERROR] DB build failed: {e}")) # Auto-export playlists at end (M3U8 + PLB + PLM) try: save_playlists_json(self.playlists_json_path, self.playlists) export_playlists_m3u8(self.out_playlists, self.playlists) entries = export_playlists_plb( self.out_playlists, self.playlists, self.library_bin_path, log_fn=lambda m: self.ui_queue.put(("log", m)), ) export_playlists_plm(self.out_db, entries, log_fn=lambda m: self.ui_queue.put(("log", m))) self.ui_queue.put(("log", f"[PLAYLIST] exported to {self.out_playlists} and {self.playlists_json_path}")) except Exception as e: self.ui_queue.put(("log", f"[WARN] playlist export failed: {e}")) self.ui_queue.put(("encode_done", None)) # -------- Playlist GUI actions -------- def _goto_library_tab(self) -> None: if hasattr(self, "nb") and hasattr(self, "tab_lib"): try: self.nb.select(self.tab_lib) except Exception: pass def _refresh_playlists_listbox(self) -> None: if not hasattr(self, "lb_playlists"): return self.lb_playlists.delete(0, "end") for name in sorted(self.playlists.keys(), key=lambda s: s.lower()): self.lb_playlists.insert("end", name) def _current_playlist_name(self) -> Optional[str]: sel = self.lb_playlists.curselection() if not sel: return None return self.lb_playlists.get(sel[0]) def _refresh_playlist_items(self) -> None: if not hasattr(self, "lb_items"): return self.lb_items.delete(0, "end") name = self._current_playlist_name() if not name: return for item in self.playlists.get(name, []): self.lb_items.insert("end", self._display_label_for_out_rel(item)) def _playlist_new(self) -> None: name = simpledialog.askstring(self.tr("new_playlist"), self.tr("playlist_name")) if not name: return name = safe_playlist_filename(name) if not name: messagebox.showerror("Playlist", self.tr("invalid_name")) return if name in self.playlists: messagebox.showerror("Playlist", "Already exists") return self.playlists[name] = [] self._refresh_playlists_listbox() self._select_playlist(name) self._persist_playlists() def _playlist_rename(self) -> None: cur = self._current_playlist_name() if not cur: return new = simpledialog.askstring(self.tr("rename_playlist"), self.tr("playlist_name"), initialvalue=cur) if not new: return new = safe_playlist_filename(new) if not new: messagebox.showerror("Playlist", self.tr("invalid_name")) return if new == cur: return if new in self.playlists: messagebox.showerror("Playlist", "Already exists") return self.playlists[new] = self.playlists.pop(cur) self._refresh_playlists_listbox() self._select_playlist(new) self._persist_playlists() def _playlist_delete(self) -> None: cur = self._current_playlist_name() if not cur: return if not messagebox.askyesno(self.tr("delete_playlist"), f"{self.tr('confirm_delete')}\n\n{cur}"): return self.playlists.pop(cur, None) self._refresh_playlists_listbox() self._refresh_playlist_items() self._persist_playlists() def _playlist_add_selected_from_tree(self, tree=None) -> None: pl = self._current_playlist_name() if not pl: messagebox.showinfo("Playlist", "Select a playlist first") return if tree is None: tree = self.tree if hasattr(self, "tree") else None sel = tree.selection() if tree is not None else () if not sel: messagebox.showinfo("Playlist", self.tr("no_selection")) return added = 0 items = self.playlists.get(pl, []) existing = set(items) for iid in sel: tags = tree.item(iid, "tags") if not tags or not tags[0].isdigit(): continue idx = int(tags[0]) job = self.jobs[idx] rel = job.rel_path if bool(self.hash_paths_var.get()): # Ensure metadata exists for stable meta-based filing root = Path(self.scan_root_var.get()).expanduser().resolve() src = job.src_path rel_key = src.relative_to(root).as_posix() sig = stat_sig(src) cache_ent = self.cache.get(rel_key) if cache_ent and tuple(cache_ent.get("sig", ())) == sig: m = cache_ent["meta"] meta = TrackMeta( out_rel_path="", title=m.get("title", src.stem), artist=m.get("artist", UNKNOWN_ARTIST), album=m.get("album", UNKNOWN_ALBUM), track_no=int(m.get("track_no", 0)), disc_no=int(m.get("disc_no", 0)), track_year=int(m.get("track_year", 0)), album_year=int(m.get("album_year", 0)), duration_ms=int(m.get("duration_ms", 0)), codec=int(m.get("codec", 0)), flags=int(m.get("flags", 0)), ) else: meta = extract_trackmeta_for_index(src, out_rel_path="MUSIC/_tmp.mp3", infer_from_path=True) self.cache[rel_key] = { "sig": list(sig), "meta": { "title": meta.title, "artist": meta.artist, "album": meta.album, "track_no": meta.track_no, "disc_no": meta.disc_no, "track_year": meta.track_year, "album_year": meta.album_year, "duration_ms": meta.duration_ms, "codec": meta.codec, "flags": meta.flags, }, } out_rel = make_out_rel_path_from_meta(rel, meta) else: out_rel = make_out_rel_path(rel, False) if out_rel not in existing: items.append(out_rel) existing.add(out_rel) added += 1 self.playlists[pl] = items self._refresh_playlist_items() self._persist_playlists() self._log_msg(f"[PLAYLIST] added {added} items to '{pl}'") def _refresh_playlist_tree(self) -> None: if not hasattr(self, "tree_pl"): return self.tree_pl.delete(*self.tree_pl.get_children()) q = (self.pl_filter_var.get() if hasattr(self, "pl_filter_var") else "").strip().lower() for idx, j in enumerate(self.jobs): out_rel = make_out_rel_path(j.rel_path, bool(self.hash_paths_var.get())) title = self._display_label_for_out_rel(out_rel) src = str(j.src_path) if q: hay = f"{title}\n{out_rel}\n{src}".lower() if q not in hay: continue self.tree_pl.insert("", "end", values=(title, out_rel, src), tags=(str(idx),)) def _playlist_remove_items(self) -> None: pl = self._current_playlist_name() if not pl: return sel = list(self.lb_items.curselection()) if not sel: return items = self.playlists.get(pl, []) # remove from end to avoid index shift for i in sorted(sel, reverse=True): if 0 <= i < len(items): items.pop(i) self.playlists[pl] = items self._refresh_playlist_items() self._persist_playlists() def _playlist_move(self, delta: int) -> None: pl = self._current_playlist_name() if not pl: return sel = list(self.lb_items.curselection()) if len(sel) != 1: return i = sel[0] items = self.playlists.get(pl, []) j = i + delta if j < 0 or j >= len(items): return items[i], items[j] = items[j], items[i] self.playlists[pl] = items self._refresh_playlist_items() self.lb_items.selection_set(j) self._persist_playlists() def _export_playlists(self) -> None: try: self._persist_playlists() export_playlists_m3u8(self.out_playlists, self.playlists) entries = export_playlists_plb(self.out_playlists, self.playlists, self.library_bin_path, log_fn=self._log_msg) export_playlists_plm(self.out_db, entries, log_fn=self._log_msg) self._log_msg(f"[PLAYLIST] exported to {self.out_playlists}") except Exception as e: messagebox.showerror("Playlist", str(e)) def _persist_playlists(self) -> None: try: save_playlists_json(self.playlists_json_path, self.playlists) except Exception as e: self._log_msg(f"[WARN] save playlists.json failed: {e}") def _select_playlist(self, name: str) -> None: # select by name in listbox for i in range(self.lb_playlists.size()): if self.lb_playlists.get(i) == name: self.lb_playlists.selection_clear(0, "end") self.lb_playlists.selection_set(i) self.lb_playlists.activate(i) self._refresh_playlist_items() return # -------- UI queue -------- def _poll_ui_queue(self) -> None: try: while True: typ, payload = self.ui_queue.get_nowait() if typ == "scan_progress": self._log_msg(f"[SCAN] found {payload} files...") elif typ == "scan_done": self._on_scan_done(int(payload)) elif typ == "row_update": self._update_row(int(payload)) elif typ == "encode_progress": done, total = payload self.progress.configure(maximum=total, value=done) elif typ == "encode_done": self._log_msg("[ENCODE] done") for i in range(len(self.jobs)): self._update_row(i) elif typ == "log": self._log_msg(str(payload)) except queue.Empty: pass self.after(100, self._poll_ui_queue) def _on_scan_done(self, count: int) -> None: self._log_msg(f"[SCAN] done total={count}") for item in self.tree.get_children(): self.tree.delete(item) for idx, j in enumerate(self.jobs): out_rel = j.out_rel or make_out_rel_path(j.rel_path, bool(self.hash_paths_var.get())) self.tree.insert("", "end", values=(j.status, out_rel, str(j.src_path)), tags=(str(idx),)) self.progress.configure(maximum=max(1, count), value=0) self._refresh_playlist_tree() def _update_row(self, idx: int) -> None: tag = str(idx) for iid in self.tree.get_children(): tags = self.tree.item(iid, "tags") if tags and tags[0] == tag: j = self.jobs[idx] status = j.status if not j.error else f"{j.status}: {j.error[:100]}" out_rel = j.out_rel or make_out_rel_path(j.rel_path, bool(self.hash_paths_var.get())) self.tree.item(iid, values=(status, out_rel, str(j.src_path))) self._refresh_playlist_tree() return def main() -> None: app = App() app.mainloop() if __name__ == "__main__": main() ``` ### SPRESENSEコード ```ini:platformio.ini [env:spresense] platform = sonyspresense board = spresense framework = arduino monitor_speed = 115200 upload_command = ${platformio.packages_dir}/tool-spresense/flash_writer/windows/flash_writer -s -c $UPLOAD_PORT -b 1000000 -d -n -s $SOURCE board_upload.maximum_size = 1048576 board_upload.maximum_ram_size = 1048576 lib_deps = olikraus/U8g2@^2.36.5 ``` ```c:include/app/AppConfig.h #pragma once #include <Arduino.h> namespace app { // 6個の物理ボタン(GPIO2〜7想定)をどの役割に割り当てるか。 // 役割は画面によって意味が変わるが、基本は「Up/Down/OK/Back/Mode/Play」想定。 struct ButtonMap { uint8_t up = 0; uint8_t down = 1; uint8_t ok = 2; uint8_t back = 3; uint8_t mode = 4; uint8_t playPause = 5; }; struct AppConfig { // ボタン入力ピン(順序は「ボタンindex=0..5」)。 // ここを変えるだけでピン差し替え可能。 // GPIO2とGPIO3の操作を入れ替え(メニューのUp/Downが逆になる) uint8_t buttonPins[6] = {3, 2, 4, 5, 6, 7}; // Buttonsクラス設定 bool buttonUsePullups = true; uint16_t buttonDebounceMs = 30; ButtonMap buttonMap{}; // --- Volume --- // UI上は 0-100[%] で扱い、Audio.setVolume() の master volume に変換して適用する。 uint8_t defaultVolumePercent = 30; uint8_t volumeStepPercent = 5; // Audio.setVolume(master) の範囲は -1020(-102dB)〜120(12dB)。 // 0dB超は歪みやすいので、通常は max=0 推奨。 int minMasterVolume = -1020; int maxMasterVolume = 0; // SDカード構成(将来: ここも設定化) const char *dbPath = "DB/library.bin"; const char *playlistsDir = "PLAYLISTS"; const char *playlistsMetaPath = "DB/playlists.bin"; const char *resumePath = "DB/resume.bin"; // 続きから用(予定) // --- Debug --- // 起動時(Loading中)に library.bin の先頭トラック情報をSerialへダンプする bool debugDumpDbSampleOnBoot = false; uint8_t debugDumpDbSampleCount = 3; // ついでに各トラックのpathをSDでopenできるか試す(時間が掛かるので必要時のみ) bool debugTryOpenTrackPathsOnBoot = false; // MUSICディレクトリが見えているかを確認する(存在チェック+中身を先頭数件表示) bool debugListMusicDirOnBoot = false; uint8_t debugListMusicDirMaxEntries = 15; // ルートディレクトリの先頭エントリも出す(SD内容が想定通りか確認用) bool debugListRootDirOnBoot = false; uint8_t debugListRootDirMaxEntries = 20; // MUSIC配下のサブディレクトリも先頭数件だけ列挙する(日本語フォルダが見えているか確認用) bool debugListMusicSubDirsOnBoot = false; uint8_t debugListMusicSubDirMaxEntries = 10; // メニュー画面にバッテリー電圧(mV)を表示する bool debugShowBatteryMvInMenu = false; // メニュー画面のUSB転送入口を無効化する bool disableUsbTransferMenu = true; }; } // namespace app ``` ```c:include/app/AppController.h #pragma once #include <Arduino.h> #include <cstring> #include <SDHCI.h> #include <Audio.h> #include <LowPower.h> #include <EEPROM.h> #include <cstddef> #include <Buttons.h> #include <OledUi.h> #include <app/AppConfig.h> #include <app/AppState.h> #include <spdb/SpdbV2Sdhci.h> #include <spdb/PlbV1Sdhci.h> #include <spdb/PlmV1Sdhci.h> #include <player/PlayerPaths.h> namespace app { class AppController { public: AppController(const AppConfig &cfg, Buttons &buttons, OledUi &oled) : cfg_(cfg), buttons_(buttons), oled_(oled) {} void begin() { state_ = AppState{}; // 起動直後はLoadingを見せてからHomeへ state_.screen = ScreenId::Loading; state_.prev = ScreenId::Home; loadingSinceMs_ = millis(); loadingMsg_[0] = '\0'; // TODO: SDからresume読み込み(cfg_.resumePath) state_.resume.valid = false; // 乱数(シャッフル用) randomSeed(static_cast<uint32_t>(millis())); lastSdLogMs_ = 0; sdLogMounted_ = false; // Persisted settings (volume/shuffle/loop) initPersist_(); } void update(uint32_t nowMs) { nowMs_ = nowMs; switch (state_.screen) { case ScreenId::Loading: updateLoading_(nowMs); break; case ScreenId::Home: updateHome_(); break; case ScreenId::PlaylistSelect: updatePlaylistSelect_(); break; case ScreenId::ArtistSelect: updateArtistSelect_(); break; case ScreenId::AlbumSelect: updateAlbumSelect_(); break; case ScreenId::YearSelect: updateYearSelect_(); break; case ScreenId::ScreenSaver: updateScreenSaver_(); break; case ScreenId::NowPlaying: updateNowPlaying_(); break; case ScreenId::UsbMsc: updateUsbMsc_(); break; case ScreenId::Error: updateError_(); break; } } void setAccel(int16_t ax, int16_t ay, int16_t az) { accelAx_ = ax; accelAy_ = ay; accelAz_ = az; } bool isScreenSaver() const { return state_.screen == ScreenId::ScreenSaver; } void onDoubleShake() { if (state_.screen != ScreenId::NowPlaying) return; if (state_.nowPlaying.playing && !state_.nowPlaying.paused) { pause_(); } else { resume_(); } } void onTripleShake() { if (state_.screen != ScreenId::NowPlaying) return; nextTrack_(); } void onOctShake() { if (state_.screen != ScreenId::ScreenSaver) return; go_(ScreenId::NowPlaying); } void render() { if (!oled_.isReady()) return; oled_.clear(); auto &g = oled_.gfx(); switch (state_.screen) { case ScreenId::Loading: renderLoading_(g); break; case ScreenId::Home: renderHome_(g); break; case ScreenId::PlaylistSelect: renderPlaylistSelect_(g); break; case ScreenId::ArtistSelect: renderArtistSelect_(g); break; case ScreenId::AlbumSelect: renderAlbumSelect_(g); break; case ScreenId::YearSelect: renderYearSelect_(g); break; case ScreenId::ScreenSaver: renderScreenSaver_(g); break; case ScreenId::NowPlaying: renderNowPlaying_(g); break; case ScreenId::UsbMsc: renderUsbMsc_(g); break; case ScreenId::Error: renderError_(g); break; } oled_.send(); } const AppState &state() const { return state_; } private: // Audio library allocates FIFO buffers via malloc() in setPlayerMode(). // Default sizes can be large enough to fail allocation depending on heap state. // Keep them >= FIFO_FRAME_SIZE (16KB for Player0) so writeFrames() can prime data. static constexpr uint32_t kAudioPlayer0FifoBytes_ = 32u * 1024u; static constexpr uint32_t kAudioPlayer1FifoBytes_ = 0; void go_(ScreenId next) { if (state_.screen == ScreenId::UsbMsc && next != ScreenId::UsbMsc) { endUsbMsc_(); } state_.prev = state_.screen; state_.screen = next; if (next == ScreenId::Home) { // Prevent carry-over of back long-press from previous screen backPressStartMs_ = nowMs_; backLongFired_ = true; } if (next == ScreenId::PlaylistSelect) { playlistScanned_ = false; } if (next == ScreenId::ArtistSelect) { artistScanned_ = false; } if (next == ScreenId::AlbumSelect) { albumScanned_ = false; } if (next == ScreenId::YearSelect) { yearsBuilt_ = false; } if (next == ScreenId::ScreenSaver) { screensaverInit_ = false; state_.screensaver.active = true; screensaverShowInfo_ = false; backPressStartMs_ = 0; backLongFired_ = false; } else { state_.screensaver.active = false; } if (next == ScreenId::UsbMsc) { beginUsbMsc_(); } if (next == ScreenId::NowPlaying) { // ページ遷移後は「再生状態で開始」 if (state_.nowPlaying.paused) { resume_(); } else if (!state_.nowPlaying.playing) { if (queue_ && queueCount_ > 0) { if (startCurrentTrack_()) { state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; } } } } } void goError_(const char *utf8) { state_.prev = state_.screen; state_.screen = ScreenId::Error; strncpy(state_.error.message, utf8 ? utf8 : "", sizeof(state_.error.message) - 1); state_.error.message[sizeof(state_.error.message) - 1] = '\0'; } bool fell_(uint8_t idx) const { return buttons_.fell(idx); } void updateLoading_(uint32_t nowMs) { // SD/DB/Audio初期化が完了するまでLoading if (!sdMounted_) { if (sd_.begin()) { sdMounted_ = true; // 一度だけ「マウント成功」を出す if (!sdLogMounted_) { Serial.println("[SD] mounted OK"); sdLogMounted_ = true; } } else { // うるさくなりすぎないように1秒間隔 if ((nowMs - lastSdLogMs_) >= 1000) { lastSdLogMs_ = nowMs; Serial.println("[SD] mount failed (insert SD card?)"); } strncpy(loadingMsg_, "Please insert SD card", sizeof(loadingMsg_) - 1); loadingMsg_[sizeof(loadingMsg_) - 1] = '\0'; return; } } if (!dbOpen_) { spdb::Status st = db_.open(cfg_.dbPath ? cfg_.dbPath : player::kDbPath); if (!st) { strncpy(loadingMsg_, "Cannot open DB/library.bin", sizeof(loadingMsg_) - 1); loadingMsg_[sizeof(loadingMsg_) - 1] = '\0'; return; } dbOpen_ = true; if (cfg_.debugDumpDbSampleOnBoot && !dbDumped_) { dumpDbSample_(); dbDumped_ = true; } } if (!audioReady_) { if (!initAudio_()) { strncpy(loadingMsg_, "Audio initialization failed", sizeof(loadingMsg_) - 1); loadingMsg_[sizeof(loadingMsg_) - 1] = '\0'; return; } audioReady_ = true; } // 少しだけLoadingを見せる if (nowMs - loadingSinceMs_ >= 200) { state_.screen = ScreenId::Home; } } void updateHome_() { if (cfg_.debugShowBatteryMvInMenu) { updateBattery_(); } const uint8_t up = cfg_.buttonMap.up; const uint8_t down = cfg_.buttonMap.down; const uint8_t ok = cfg_.buttonMap.ok; const uint8_t back = cfg_.buttonMap.back; const int8_t upAlt = indexByPin_(6); const int8_t downAlt = indexByPin_(7); // Menu items ("続きから" and "設定" are intentionally hidden) const uint8_t kItemCount = cfg_.disableUsbTransferMenu ? 5 : 6; if (state_.home.selected >= kItemCount) { state_.home.selected = 0; } if (fell_(up) || (upAlt >= 0 && fell_(static_cast<uint8_t>(upAlt)))) { state_.home.selected = (state_.home.selected + kItemCount - 1) % kItemCount; } if (fell_(down) || (downAlt >= 0 && fell_(static_cast<uint8_t>(downAlt)))) { state_.home.selected = (state_.home.selected + 1) % kItemCount; } if (fell_(back)) { backPressStartMs_ = nowMs_; backLongFired_ = false; } if (pressed_(back) && !backLongFired_) { if ((nowMs_ - backPressStartMs_) >= kBackLongPressMs_) { go_(ScreenId::UsbMsc); backLongFired_ = true; return; } } if (fell_(ok)) { switch (state_.home.selected) { case 0: // プレイリスト go_(ScreenId::PlaylistSelect); break; case 1: // 全曲 if (!startAllTracks_()) { // startAllTracks_がエラー画面へ遷移する } break; case 2: // アーティスト go_(ScreenId::ArtistSelect); break; case 3: // アルバム go_(ScreenId::AlbumSelect); break; case 4: // 年別 go_(ScreenId::YearSelect); break; case 5: // USB Transfer if (!cfg_.disableUsbTransferMenu) { go_(ScreenId::UsbMsc); } break; } } } void updatePlaylistSelect_() { const uint8_t up = cfg_.buttonMap.up; const uint8_t down = cfg_.buttonMap.down; const uint8_t ok = cfg_.buttonMap.ok; const uint8_t back = cfg_.buttonMap.back; const int8_t upAlt = indexByPin_(6); const int8_t downAlt = indexByPin_(7); if (!playlistScanned_) { scanPlaylists_(); playlistScanned_ = true; state_.playlist.selected = 0; } const uint8_t kItemCount = (playlistCount_ > 0) ? playlistCount_ : 1; if (fell_(back)) { go_(ScreenId::Home); return; } if (fell_(up) || (upAlt >= 0 && fell_(static_cast<uint8_t>(upAlt)))) { state_.playlist.selected = (state_.playlist.selected + kItemCount - 1) % kItemCount; } if (fell_(down) || (downAlt >= 0 && fell_(static_cast<uint8_t>(downAlt)))) { state_.playlist.selected = (state_.playlist.selected + 1) % kItemCount; } if (fell_(ok)) { if (playlistCount_ == 0) { goError_("No PLAYLISTS/.plb files"); return; } const uint8_t sel = state_.playlist.selected; if (sel >= playlistCount_) { goError_("Invalid playlist selection"); return; } if (!startPlaylist_(playlistPaths_[sel], playlistNames_[sel])) { // startPlaylist_がエラー画面へ遷移する } } } void updateArtistSelect_() { const uint8_t up = cfg_.buttonMap.up; const uint8_t down = cfg_.buttonMap.down; const uint8_t ok = cfg_.buttonMap.ok; const uint8_t back = cfg_.buttonMap.back; const int8_t upAlt = indexByPin_(6); const int8_t downAlt = indexByPin_(7); if (!artistScanned_) { artistCount_ = dbOpen_ ? db_.artistCount() : 0; if (state_.artist.selected >= artistCount_) state_.artist.selected = 0; artistScanned_ = true; } const uint16_t kItemCount = (artistCount_ > 0) ? artistCount_ : 1; if (fell_(back)) { go_(ScreenId::Home); return; } if (fell_(up) || (upAlt >= 0 && fell_(static_cast<uint8_t>(upAlt)))) { state_.artist.selected = (state_.artist.selected + kItemCount - 1) % kItemCount; } if (fell_(down) || (downAlt >= 0 && fell_(static_cast<uint8_t>(downAlt)))) { state_.artist.selected = (state_.artist.selected + 1) % kItemCount; } if (fell_(ok)) { if (artistCount_ == 0) { goError_("No artists"); return; } const uint16_t sel = state_.artist.selected; if (sel >= artistCount_) { goError_("Invalid artist selection"); return; } if (!startArtist_(static_cast<spdb::ArtistId>(sel))) { // startArtist_がエラー画面へ遷移する } } } void updateAlbumSelect_() { const uint8_t up = cfg_.buttonMap.up; const uint8_t down = cfg_.buttonMap.down; const uint8_t ok = cfg_.buttonMap.ok; const uint8_t back = cfg_.buttonMap.back; const int8_t upAlt = indexByPin_(6); const int8_t downAlt = indexByPin_(7); if (!albumScanned_) { albumCount_ = dbOpen_ ? db_.albumCount() : 0; if (state_.album.selected >= albumCount_) state_.album.selected = 0; albumScanned_ = true; } const uint16_t kItemCount = (albumCount_ > 0) ? albumCount_ : 1; if (fell_(back)) { go_(ScreenId::Home); return; } if (fell_(up) || (upAlt >= 0 && fell_(static_cast<uint8_t>(upAlt)))) { state_.album.selected = (state_.album.selected + kItemCount - 1) % kItemCount; } if (fell_(down) || (downAlt >= 0 && fell_(static_cast<uint8_t>(downAlt)))) { state_.album.selected = (state_.album.selected + 1) % kItemCount; } if (fell_(ok)) { if (albumCount_ == 0) { goError_("No albums"); return; } const uint16_t sel = state_.album.selected; if (sel >= albumCount_) { goError_("Invalid album selection"); return; } if (!startAlbum_(static_cast<spdb::AlbumId>(sel))) { // startAlbum_がエラー画面へ遷移する } } } void updateYearSelect_() { const uint8_t up = cfg_.buttonMap.up; const uint8_t down = cfg_.buttonMap.down; const uint8_t ok = cfg_.buttonMap.ok; const uint8_t back = cfg_.buttonMap.back; const int8_t upAlt = indexByPin_(6); const int8_t downAlt = indexByPin_(7); if (!yearsBuilt_) { buildYearList_(); if (state_.year.selected >= yearCount_) state_.year.selected = 0; yearsBuilt_ = true; } const uint16_t kItemCount = (yearCount_ > 0) ? yearCount_ : 1; if (fell_(back)) { go_(ScreenId::Home); return; } if (fell_(up) || (upAlt >= 0 && fell_(static_cast<uint8_t>(upAlt)))) { state_.year.selected = (state_.year.selected + kItemCount - 1) % kItemCount; } if (fell_(down) || (downAlt >= 0 && fell_(static_cast<uint8_t>(downAlt)))) { state_.year.selected = (state_.year.selected + 1) % kItemCount; } if (fell_(ok)) { if (yearCount_ == 0) { goError_("No years"); return; } const uint16_t sel = state_.year.selected; if (sel >= yearCount_) { goError_("Invalid year selection"); return; } const uint16_t year = yearValues_ ? yearValues_[sel] : 0; if (!startYear_(year)) { // startYear_がエラー画面へ遷移する } } } void updateNowPlaying_() { // NowPlaying only: GPIO mapping requested by user // GPIO7: Prev / GPIO6: Next / GPIO5: Back Screen / GPIO4: Play/Pause (long: toggle shuffle/loop) // GPIO3: Vol Up / GPIO2: Vol Down const int8_t back = indexByPin_(5); const int8_t prev = indexByPin_(7); const int8_t next = indexByPin_(6); const int8_t play = indexByPin_(4); const int8_t volUp = indexByPin_(3); const int8_t volDown = indexByPin_(2); processAudio_(); updateBattery_(); if (back >= 0) { const uint8_t backIdx = static_cast<uint8_t>(back); if (fell_(backIdx)) { backPressStartMs_ = nowMs_; backLongFired_ = false; } if (pressed_(backIdx) && !backLongFired_) { if ((nowMs_ - backPressStartMs_) >= kBackLongPressMs_) { go_(ScreenId::ScreenSaver); backLongFired_ = true; return; } } if (rose_(backIdx)) { if (!backLongFired_) { go_(ScreenId::Home); return; } } } // Volume if (volUp >= 0 && fell_(static_cast<uint8_t>(volUp))) { volumeStep_(+static_cast<int8_t>(cfg_.volumeStepPercent)); } if (volDown >= 0 && fell_(static_cast<uint8_t>(volDown))) { volumeStep_(-static_cast<int8_t>(cfg_.volumeStepPercent)); } // Track prev/next if (prev >= 0 && fell_(static_cast<uint8_t>(prev))) { prevTrack_(); } if (next >= 0 && fell_(static_cast<uint8_t>(next))) { nextTrack_(); } // Play/Pause short press, long press toggles shuffle/loop modes. if (play >= 0) { const uint8_t playIdx = static_cast<uint8_t>(play); if (fell_(playIdx)) { playPressStartMs_ = nowMs_; playLongFired_ = false; } if (pressed_(playIdx) && !playLongFired_) { if ((nowMs_ - playPressStartMs_) >= kPlayLongPressMs_) { cycleShuffleLoop_(); playLongFired_ = true; } } if (rose_(playIdx)) { if (!playLongFired_) { if (state_.nowPlaying.playing) { pause_(); } else if (state_.nowPlaying.paused) { resume_(); } else { // 停止状態 → 現在曲を先頭から再生 if (!startCurrentTrack_()) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; } else { state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; } } } } } } void updateScreenSaver_() { // Keep audio running and allow normal playback controls processAudio_(); updateBattery_(); const int8_t back = indexByPin_(5); const int8_t prev = indexByPin_(7); const int8_t next = indexByPin_(6); const int8_t play = indexByPin_(4); const int8_t volUp = indexByPin_(3); const int8_t volDown = indexByPin_(2); if (back >= 0) { const uint8_t backIdx = static_cast<uint8_t>(back); if (fell_(backIdx)) { backPressStartMs_ = nowMs_; backLongFired_ = false; } if (pressed_(backIdx) && !backLongFired_) { if ((nowMs_ - backPressStartMs_) >= kBackLongPressMs_) { screensaverShowInfo_ = !screensaverShowInfo_; backLongFired_ = true; } } if (rose_(backIdx)) { if (!backLongFired_) { go_(ScreenId::NowPlaying); return; } } } if (volUp >= 0 && fell_(static_cast<uint8_t>(volUp))) { volumeStep_(+static_cast<int8_t>(cfg_.volumeStepPercent)); } if (volDown >= 0 && fell_(static_cast<uint8_t>(volDown))) { volumeStep_(-static_cast<int8_t>(cfg_.volumeStepPercent)); } if (prev >= 0 && fell_(static_cast<uint8_t>(prev))) { prevTrack_(); } if (next >= 0 && fell_(static_cast<uint8_t>(next))) { nextTrack_(); } if (play >= 0) { const uint8_t playIdx = static_cast<uint8_t>(play); if (fell_(playIdx)) { playPressStartMs_ = nowMs_; playLongFired_ = false; } if (pressed_(playIdx) && !playLongFired_) { if ((nowMs_ - playPressStartMs_) >= kPlayLongPressMs_) { cycleShuffleLoop_(); playLongFired_ = true; } } if (rose_(playIdx)) { if (!playLongFired_) { if (state_.nowPlaying.playing) { pause_(); } else if (state_.nowPlaying.paused) { resume_(); } else { if (!startCurrentTrack_()) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; } else { state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; } } } } } if (!screensaverInit_) { initScreenSaver_(); } updateBubbles_(); } void updateError_() { const uint8_t back = cfg_.buttonMap.back; const uint8_t ok = cfg_.buttonMap.ok; if (fell_(back) || fell_(ok)) { // エラーから戻る state_.screen = state_.prev; } } void updateUsbMsc_() { const uint8_t back = cfg_.buttonMap.back; const uint8_t ok = cfg_.buttonMap.ok; if (fell_(back) || fell_(ok)) { go_(ScreenId::Home); } } void renderLoading_(U8G2 &g) { drawTopBar_(g, "Loading"); g.setFont(u8g2_font_b12_t_japanese2); drawEllipsizedUtf8_(g, 0, 28, 128, loadingMsg_[0] ? loadingMsg_ : "Checking SD/DB/Audio"); g.setCursor(0, 44); g.print("Please wait..."); } void renderHome_(U8G2 &g) { drawTopBar_(g, "MENU"); // "続きから" and "設定" are hidden per request. static const char *items[] = { "Playlists", "All Tracks", "Artist", "Album", "Year", "USB Transfer", }; static const uint8_t *icons[] = { kIconPlaylist, kIconMusic, kIconArtist, kIconAlbum, kIconYear, kIconUsb, }; const uint8_t kItemCount = cfg_.disableUsbTransferMenu ? 5 : 6; // Infinite-scroll style: selected item centered, wrap around. static constexpr int8_t kOffsets[5] = {-2, -1, 0, 1, 2}; const uint8_t y0 = 12; const uint8_t rowH = 10; for (uint8_t slot = 0; slot < 5; ++slot) { const int8_t off = kOffsets[slot]; int idx = static_cast<int>(state_.home.selected) + off; while (idx < 0) idx += kItemCount; while (idx >= kItemCount) idx -= kItemCount; const uint8_t yTop = static_cast<uint8_t>(y0 + slot * rowH); const bool sel = (off == 0); if (sel) { g.setDrawColor(1); g.drawBox(0, yTop, 128, rowH); g.setDrawColor(0); g.setFont(u8g2_font_b10_t_japanese2); } else { g.setDrawColor(1); g.setFont(u8g2_font_6x10_tr); } const uint8_t iconYTop = static_cast<uint8_t>(yTop + ((rowH > 8) ? ((rowH - 8) / 2) : 0)); drawIcon8_(g, 2, iconYTop, icons[idx]); drawEllipsizedUtf8_(g, 14, static_cast<uint8_t>(yTop + rowH - 2), 112, items[idx]); if (sel) { g.setDrawColor(1); } } // Small hint g.setFont(u8g2_font_b10_t_japanese2); g.setCursor(0, 63); if (cfg_.debugShowBatteryMvInMenu) { char mv[20] = {}; if (batteryMv_ == 0) { snprintf(mv, sizeof(mv), "BATT -- mV"); } else { snprintf(mv, sizeof(mv), "BATT %u mV", static_cast<unsigned>(batteryMv_)); } g.print(mv); } // g.print("OK:決定 ↑↓:移動"); } void renderPlaylistSelect_(U8G2 &g) { char leftHdr[16] = {}; snprintf(leftHdr, sizeof(leftHdr), "PLAYLIST"); char rightHdr[16] = {}; if (playlistCount_ > 0) { snprintf(rightHdr, sizeof(rightHdr), "%u/%u", static_cast<unsigned>(state_.playlist.selected + 1), static_cast<unsigned>(playlistCount_)); } drawTopBarLR_(g, leftHdr, rightHdr); g.setFont(u8g2_font_b12_t_japanese3); if (playlistCount_ == 0) { g.setCursor(0, 30); g.print("( No PLAYLISTS/.plb files )"); g.setCursor(0, 56); g.print("Back : Home"); return; } // Page-based list: 4 rows per page static constexpr uint8_t kRows = 4; const int sel = state_.playlist.selected; const int start = (sel / kRows) * kRows; const uint8_t y0 = 13; const uint8_t rowH = 12; // Marquee timing for selected playlist name const uint32_t plKey = hashText_(playlistNames_[sel]); if (plKey != marqueePlKey_) { marqueePlKey_ = plKey; marqueePlStartMs_ = nowMs_; } for (uint8_t row = 0; row < kRows; ++row) { const int i = start + row; if (i >= playlistCount_) break; const uint8_t yTop = static_cast<uint8_t>(y0 + row * rowH); drawPlaylistRow_(g, yTop, rowH, (i == sel), static_cast<uint8_t>(i + 1), playlistNames_[i], nowMs_, marqueePlStartMs_); } } void renderScreenSaver_(U8G2 &g) { g.clearBuffer(); for (uint8_t i = 0; i < kBubbleCount_; ++i) { const Bubble &b = bubbles_[i]; const int16_t x = static_cast<int16_t>(b.x); const int16_t y = static_cast<int16_t>(b.y); if (b.r <= 1) { g.drawPixel(x, y); } else { g.drawCircle(x, y, b.r, U8G2_DRAW_ALL); // highlight for larger bubbles if (b.r >= 3) { g.drawPixel(static_cast<int16_t>(x - 1), static_cast<int16_t>(y - 1)); if (b.r >= 4) { g.drawPixel(static_cast<int16_t>(x - 2), static_cast<int16_t>(y - 2)); } } } } if (screensaverShowInfo_) { // Smart overlay: title + artist (no background fill) g.setDrawColor(1); g.setFont(u8g2_font_b12_t_japanese3); drawEllipsizedUtf8_(g, 2, 64 - 15, 124, state_.nowPlaying.title); g.setFont(u8g2_font_b12_t_japanese3); if (state_.nowPlaying.artist[0]) { drawEllipsizedUtf8_(g, 2, 64 - 2, 124, state_.nowPlaying.artist); } else if (state_.nowPlaying.album[0]) { drawEllipsizedUtf8_(g, 2, 64 - 2, 124, state_.nowPlaying.album); } } } void renderUsbMsc_(U8G2 &g) { drawTopBar_(g, "USB TRANSFER"); g.setFont(u8g2_font_b12_t_japanese2); g.setCursor(0, 26); g.print("USB Mass Storage"); g.setFont(u8g2_font_b10_t_japanese2); g.setCursor(0, 42); if (usbMscError_) { g.print("Preparation failed"); } else if (usbMscActive_) { g.print("Accessible via USB"); } else { g.print("Preparing..."); } g.setCursor(0, 60); g.print("OK/Back : Home"); } void initScreenSaver_() { for (uint8_t i = 0; i < kBubbleCount_; ++i) { bubbles_[i].r = static_cast<uint8_t>(1 + (random(0, 5))); bubbles_[i].x = static_cast<float>(random(0, 128)); bubbles_[i].y = static_cast<float>(random(0, 64)); bubbles_[i].vx = 0.0f; bubbles_[i].vy = -0.3f - (static_cast<float>(random(0, 100)) / 400.0f); } screensaverInit_ = true; } void updateBubbles_() { // Use accelerometer to determine "up" direction (opposite gravity) const float ax = static_cast<float>(accelAx_); const float ay = static_cast<float>(accelAy_); const float az = static_cast<float>(accelAz_); const float mag = sqrtf(ax * ax + ay * ay + az * az); if (mag > 0.1f) { const float gx = ax / mag; const float gy = ay / mag; // Screen-left is treated as "up" (rotate vector 90deg) float upx = -gy; float upy = gx; const float upMag = sqrtf(upx * upx + upy * upy); if (upMag > 0.05f) { upx /= upMag; upy /= upMag; screensaverUpX_ = upx; screensaverUpY_ = upy; } } const float base = 0.28f; for (uint8_t i = 0; i < kBubbleCount_; ++i) { Bubble &b = bubbles_[i]; // Drift toward "up" with tilt influence b.vx = (b.vx * 0.85f) + (screensaverUpX_ * 0.15f); b.vy = (b.vy * 0.85f) + (screensaverUpY_ * 0.15f); b.x += b.vx + (screensaverUpX_ * base); b.y += b.vy + (screensaverUpY_ * base); if (b.x < -5) b.x = 128 + b.r; if (b.x > 128 + 5) b.x = -b.r; if (b.y < -b.r) { b.y = 64 + b.r; b.x = static_cast<float>(random(0, 128)); b.vx = 0.0f; b.vy = 0.0f; } if (b.y > 64 + b.r) { b.y = -b.r; b.x = static_cast<float>(random(0, 128)); b.vx = 0.0f; b.vy = 0.0f; } } } void renderArtistSelect_(U8G2 &g) { char rightHdr[16] = {}; if (artistCount_ > 0) { snprintf(rightHdr, sizeof(rightHdr), "%u/%u", static_cast<unsigned>(state_.artist.selected + 1), static_cast<unsigned>(artistCount_)); } drawTopBarLR_(g, "ARTIST", rightHdr); g.setFont(u8g2_font_b12_t_japanese3); if (artistCount_ == 0) { g.setCursor(0, 30); g.print("( Artistなし )"); g.setCursor(0, 56); g.print("Backで戻る"); return; } static constexpr uint8_t kRows = 4; const int sel = state_.artist.selected; const int start = (sel / kRows) * kRows; const uint8_t y0 = 13; const uint8_t rowH = 12; for (uint8_t row = 0; row < kRows; ++row) { const int i = start + row; if (i >= artistCount_) break; char nameBuf[256] = {}; spdb::ArtistRec ar{}; if (db_.readArtist(static_cast<spdb::ArtistId>(i), ar)) { (void)db_.readString(ar.name, nameBuf, sizeof(nameBuf)); } if (!nameBuf[0]) strncpy(nameBuf, "(Unknown)", sizeof(nameBuf) - 1); const uint8_t yTop = static_cast<uint8_t>(y0 + row * rowH); if (i == sel) { const uint32_t key = hashText_(nameBuf); if (key != marqueeArtistKey_) { marqueeArtistKey_ = key; marqueeArtistStartMs_ = nowMs_; } } drawListRowMarquee_(g, yTop, rowH, (i == sel), nameBuf, nowMs_, marqueeArtistStartMs_); } } void renderAlbumSelect_(U8G2 &g) { char rightHdr[16] = {}; if (albumCount_ > 0) { snprintf(rightHdr, sizeof(rightHdr), "%u/%u", static_cast<unsigned>(state_.album.selected + 1), static_cast<unsigned>(albumCount_)); } drawTopBarLR_(g, "ALBUM", rightHdr); g.setFont(u8g2_font_b12_t_japanese3); if (albumCount_ == 0) { g.setCursor(0, 30); g.print("( Albumなし )"); g.setCursor(0, 56); g.print("Backで戻る"); return; } static constexpr uint8_t kRows = 4; const int sel = state_.album.selected; const int start = (sel / kRows) * kRows; const uint8_t y0 = 13; const uint8_t rowH = 12; for (uint8_t row = 0; row < kRows; ++row) { const int i = start + row; if (i >= albumCount_) break; char nameBuf[256] = {}; spdb::AlbumRec al{}; if (db_.readAlbum(static_cast<spdb::AlbumId>(i), al)) { (void)db_.readString(al.name, nameBuf, sizeof(nameBuf)); } if (!nameBuf[0]) strncpy(nameBuf, "(Unknown)", sizeof(nameBuf) - 1); const uint8_t yTop = static_cast<uint8_t>(y0 + row * rowH); if (i == sel) { const uint32_t key = hashText_(nameBuf); if (key != marqueeAlbumKey_) { marqueeAlbumKey_ = key; marqueeAlbumStartMs_ = nowMs_; } } drawListRowMarquee_(g, yTop, rowH, (i == sel), nameBuf, nowMs_, marqueeAlbumStartMs_); } } void renderYearSelect_(U8G2 &g) { char rightHdr[16] = {}; if (yearCount_ > 0) { snprintf(rightHdr, sizeof(rightHdr), "%u/%u", static_cast<unsigned>(state_.year.selected + 1), static_cast<unsigned>(yearCount_)); } drawTopBarLR_(g, "YEAR", rightHdr); g.setFont(u8g2_font_b12_t_japanese2); if (yearCount_ == 0) { g.setCursor(0, 30); g.print("( 年別なし )"); g.setCursor(0, 56); g.print("Backで戻る"); return; } static constexpr uint8_t kRows = 4; const int sel = state_.year.selected; const int start = (sel / kRows) * kRows; const uint8_t y0 = 13; const uint8_t rowH = 12; for (uint8_t row = 0; row < kRows; ++row) { const int i = start + row; if (i >= yearCount_) break; char label[16] = {}; const uint16_t y = yearValues_ ? yearValues_[i] : 0; if (y > 0) { snprintf(label, sizeof(label), "%u", static_cast<unsigned>(y)); } else { strncpy(label, "(Unknown)", sizeof(label) - 1); } const uint8_t yTop = static_cast<uint8_t>(y0 + row * rowH); drawListRow_(g, yTop, rowH, (i == sel), label); } } void renderNowPlaying_(U8G2 &g) { // Layout (as requested): // 1) music icon + Title (marquee) + battery icon // 2) person icon + Artist (marquee) // 3) time + play + LS + speaker+vol + idx/total // 4) progress bar static constexpr uint8_t kXIcon = 0; static constexpr uint8_t kXText = 12; static constexpr uint8_t kYTitle = 13; static constexpr uint8_t kYArtist = 29; static constexpr uint8_t kYStatus = 45; const uint8_t titleIconYTop = static_cast<uint8_t>(kYTitle - 8); const uint8_t artistIconYTop = static_cast<uint8_t>(kYArtist - 8); const uint8_t statusIconYTop = static_cast<uint8_t>(kYStatus - 8); // --- Line 1: ♪ Title ... [battery] --- g.setFont(u8g2_font_b12_t_japanese3); drawIcon8_(g, kXIcon, titleIconYTop, kIconMusic); // Battery icon driven by LowPower.getVoltage() drawIcon8_(g, 128 - 8, titleIconYTop, batteryIcon_()); const uint8_t titleW = 128 - kXText - 10; const uint32_t titleKey = hashText_(state_.nowPlaying.title); if (titleKey != marqueeTitleKey_) { marqueeTitleKey_ = titleKey; marqueeTitleStartMs_ = nowMs_; } drawMarqueeUtf8_(g, kXText, kYTitle, titleW, 14, state_.nowPlaying.title, nowMs_, marqueeTitleStartMs_); // --- Line 2: 🤵 Artist/Album --- g.setFont(u8g2_font_b12_t_japanese3); drawIcon8_(g, kXIcon, artistIconYTop, kIconPerson); char meta[512] = {}; if (state_.nowPlaying.artist[0] && state_.nowPlaying.album[0]) { snprintf(meta, sizeof(meta), "%s/%s", state_.nowPlaying.artist, state_.nowPlaying.album); } else if (state_.nowPlaying.artist[0]) { strncpy(meta, state_.nowPlaying.artist, sizeof(meta) - 1); meta[sizeof(meta) - 1] = '\0'; } else if (state_.nowPlaying.album[0]) { strncpy(meta, state_.nowPlaying.album, sizeof(meta) - 1); meta[sizeof(meta) - 1] = '\0'; } const uint32_t metaKey = hashText_(meta); if (metaKey != marqueeMetaKey_) { marqueeMetaKey_ = metaKey; marqueeMetaStartMs_ = nowMs_; } drawMarqueeUtf8_(g, kXText, kYArtist, 128 - kXText, 14, meta, nowMs_, marqueeMetaStartMs_); // --- Line 3: 00:00/03:00 ▷LS 📢100 1/64 --- g.setFont(u8g2_font_b10_t_japanese2); char idxBuf[16] = {}; if (state_.nowPlaying.queueCount > 0) { snprintf(idxBuf, sizeof(idxBuf), "%u/%u", static_cast<unsigned>(state_.nowPlaying.queueIndex + 1), static_cast<unsigned>(state_.nowPlaying.queueCount)); } else { strncpy(idxBuf, "--/--", sizeof(idxBuf) - 1); idxBuf[sizeof(idxBuf) - 1] = '\0'; } const bool showIdx = (state_.nowPlaying.queueCount < 100) || (nowMs_ <= showTrackIndexUntilMs_); const int16_t idxW = showIdx ? g.getUTF8Width(idxBuf) : 0; const uint8_t idxX = showIdx ? static_cast<uint8_t>((idxW < 120) ? (128 - idxW) : 0) : 128; if (showIdx) { g.drawUTF8(idxX, kYStatus, idxBuf); } char timeBuf[20] = {}; { char el[8] = {}; char tot[8] = {}; const uint32_t elapsedMs = estimateElapsedMs_(); formatTimeMmSs_(elapsedMs, el, sizeof(el)); formatTimeMmSs_(state_.nowPlaying.durationMs, tot, sizeof(tot)); snprintf(timeBuf, sizeof(timeBuf), "%s/%s", el, tot); } // Place volume block right-aligned (left of idx) so it doesn't disappear char volBuf[6] = {}; snprintf(volBuf, sizeof(volBuf), "%u", static_cast<unsigned>(state_.nowPlaying.volumePercent)); const int16_t volW = g.getUTF8Width(volBuf); const int16_t vW = g.getUTF8Width("V"); const uint8_t volBlockW = static_cast<uint8_t>(((vW > 0) ? vW : 6) + 1 + ((volW > 0) ? volW : 0)); const uint8_t rightLimit = showIdx ? ((idxX > 1) ? static_cast<uint8_t>(idxX - 1) : 0) : 128; static constexpr uint8_t kGapPx = 4; const bool drawVol = (rightLimit > static_cast<uint8_t>(volBlockW + kGapPx + 2)); const uint8_t volX = drawVol ? static_cast<uint8_t>(rightLimit - volBlockW - kGapPx) : 0; if (drawVol) { // Volume label: use 'V' instead of a speaker bitmap (more readable at 8px) g.drawUTF8(volX, kYStatus, "V"); g.drawUTF8(static_cast<uint8_t>(volX + ((vW > 0) ? vW : 6) + 1), kYStatus, volBuf); } // Left side: time + (optional) play icon + LS, clipped so it won't overwrite volume/idx const uint8_t leftMaxX = drawVol ? ((volX > kGapPx) ? static_cast<uint8_t>(volX - kGapPx) : 0) : rightLimit; if (leftMaxX > 2) { const uint8_t clipY0 = (kYStatus > 12) ? static_cast<uint8_t>(kYStatus - 12) : 0; g.setClipWindow(0, clipY0, static_cast<uint8_t>(leftMaxX - 1), static_cast<uint8_t>(kYStatus + 1)); g.drawUTF8(0, kYStatus, timeBuf); uint8_t x = static_cast<uint8_t>(g.getUTF8Width(timeBuf) + 2); if (x + 8 <= leftMaxX) { if (state_.nowPlaying.playing) { drawIcon8_(g, x, static_cast<uint8_t>(statusIconYTop + 1), kIconPlay); } else if (state_.nowPlaying.paused) { drawIcon8_(g, x, static_cast<uint8_t>(statusIconYTop + 1), kIconPause); } else { drawIcon8_(g, x, statusIconYTop, kIconStop); } x = static_cast<uint8_t>(x + 9); } if (x + 12 <= leftMaxX) { char ls[3] = { static_cast<char>(state_.nowPlaying.loop ? 'L' : '-'), static_cast<char>(state_.nowPlaying.shuffle ? 'S' : '-'), '\0', }; g.drawUTF8(x, kYStatus, ls); } g.setMaxClipWindow(); } // --- Progress bar (bigger framed style) --- const uint8_t barX = 0; const uint8_t barY = 50; const uint8_t barW = 128; const uint8_t barH = 12; g.drawFrame(barX, barY, barW, barH); if (state_.nowPlaying.durationMs > 0) { const uint32_t elapsed = estimateElapsedMs_(); const uint8_t fillW = static_cast<uint8_t>((static_cast<uint64_t>(barW - 2) * elapsed) / state_.nowPlaying.durationMs); if (fillW > 0) { g.drawBox(barX + 1, barY + 2, fillW, barH - 4); } } } void renderError_(U8G2 &g) { drawTopBar_(g, "エラー"); g.setFont(u8g2_font_b12_t_japanese2); drawEllipsizedUtf8_(g, 0, 32, 128, state_.error.message); g.setCursor(0, 56); g.print("OK/Backで戻る"); } // ---- UI helpers ---- static void drawTopBar_(U8G2 &g, const char *titleUtf8) { g.setDrawColor(1); g.drawBox(0, 0, 128, 12); g.setDrawColor(0); // トップバーは情報量優先で10px g.setFont(u8g2_font_b10_t_japanese2); g.setCursor(1, 10); g.print(titleUtf8 ? titleUtf8 : ""); g.setDrawColor(1); } static void drawTopBarLR_(U8G2 &g, const char *leftUtf8, const char *rightUtf8) { g.setDrawColor(1); g.drawBox(0, 0, 128, 12); g.setDrawColor(0); g.setFont(u8g2_font_b10_t_japanese2); g.setCursor(1, 10); g.print(leftUtf8 ? leftUtf8 : ""); if (rightUtf8 && *rightUtf8) { const int16_t w = g.getUTF8Width(rightUtf8); const int16_t x = (w < 128) ? (128 - 1 - w) : 0; g.setCursor(x, 10); g.print(rightUtf8); } g.setDrawColor(1); } static size_t utf8PrevBoundary_(const char *s, size_t i) { // Move left until we are at the start of a UTF-8 codepoint. while (i > 0 && (static_cast<uint8_t>(s[i]) & 0xC0) == 0x80) { --i; } return i; } static void drawEllipsizedUtf8_(U8G2 &g, uint8_t x, uint8_t y, uint8_t maxW, const char *utf8) { if (!utf8) return; if (!*utf8) return; if (g.getUTF8Width(utf8) <= maxW) { g.drawUTF8(x, y, utf8); return; } // Create "..." ellipsized string safely for UTF-8. char base[128] = {}; strncpy(base, utf8, sizeof(base) - 1); base[sizeof(base) - 1] = '\0'; const char *ellipsis = "..."; size_t n = strlen(base); while (n > 0) { n = utf8PrevBoundary_(base, n - 1); base[n] = '\0'; char tmp[132] = {}; strncpy(tmp, base, sizeof(tmp) - 1); tmp[sizeof(tmp) - 1] = '\0'; strncat(tmp, ellipsis, sizeof(tmp) - strlen(tmp) - 1); if (g.getUTF8Width(tmp) <= maxW) { g.drawUTF8(x, y, tmp); return; } } // Fallback: draw nothing if even "..." does not fit. } static void drawListRow_(U8G2 &g, uint8_t yTop, uint8_t h, bool selected, const char *labelUtf8) { // Selection highlight if (selected) { g.setDrawColor(1); g.drawBox(0, yTop, 128, h); g.setDrawColor(0); } else { g.setDrawColor(1); } const uint8_t yBase = yTop + h - 2; // baseline tweak if (selected) { // Draw label in inverted color drawEllipsizedUtf8_(g, 2, yBase, 124, labelUtf8 ? labelUtf8 : ""); g.setDrawColor(1); } else { drawEllipsizedUtf8_(g, 2, yBase, 124, labelUtf8 ? labelUtf8 : ""); } } static void drawListRowMarquee_(U8G2 &g, uint8_t yTop, uint8_t h, bool selected, const char *labelUtf8, uint32_t nowMs, uint32_t marqueeStartMs) { if (selected) { g.setDrawColor(1); g.drawBox(0, yTop, 128, h); g.setDrawColor(0); } else { g.setDrawColor(1); } const uint8_t yBase = yTop + h - 2; if (selected) { drawMarqueeUtf8_(g, 2, yBase, 124, h, labelUtf8 ? labelUtf8 : "", nowMs, marqueeStartMs); g.setDrawColor(1); } else { drawEllipsizedUtf8_(g, 2, yBase, 124, labelUtf8 ? labelUtf8 : ""); } } static void drawListRowIcon_(U8G2 &g, uint8_t yTop, uint8_t h, bool selected, const uint8_t *icon8, const char *labelUtf8) { if (selected) { g.setDrawColor(1); g.drawBox(0, yTop, 128, h); g.setDrawColor(0); } else { g.setDrawColor(1); } const uint8_t yBase = yTop + h - 3; const uint8_t iconYTop = static_cast<uint8_t>(yTop + ((h > 8) ? ((h - 8) / 2) : 0)); drawIcon8_(g, 2, iconYTop, icon8); drawEllipsizedUtf8_(g, 14, yBase, 112, labelUtf8 ? labelUtf8 : ""); if (selected) { g.setDrawColor(1); } } static void drawPlaylistRow_(U8G2 &g, uint8_t yTop, uint8_t h, bool selected, uint8_t index1, const char *nameUtf8, uint32_t nowMs, uint32_t marqueeStartMs) { if (selected) { g.setDrawColor(1); g.drawBox(0, yTop, 128, h); g.setDrawColor(0); } else { g.setDrawColor(1); } const uint8_t yBase = yTop + h - 2; char num[6] = {}; snprintf(num, sizeof(num), "%2u", static_cast<unsigned>(index1)); g.drawUTF8(2, yBase, num); const uint8_t xText = 22; const uint8_t wText = static_cast<uint8_t>((128 > (xText + 2)) ? (128 - xText - 2) : 0); if (selected) { drawMarqueeUtf8_(g, xText, yBase, wText, h, nameUtf8 ? nameUtf8 : "", nowMs, marqueeStartMs); g.setDrawColor(1); } else { drawEllipsizedUtf8_(g, xText, yBase, wText, nameUtf8 ? nameUtf8 : ""); } } static void formatTimeMmSs_(uint32_t ms, char *out, size_t outSize) { if (!out || outSize == 0) return; if (ms == 0) { strncpy(out, "--:--", outSize - 1); out[outSize - 1] = '\0'; return; } const uint32_t totalSec = ms / 1000u; const uint32_t mm = totalSec / 60u; const uint32_t ss = totalSec % 60u; snprintf(out, outSize, "%02lu:%02lu", static_cast<unsigned long>(mm), static_cast<unsigned long>(ss)); } uint32_t estimateElapsedMs_() { // ファイル位置比率はVBR/ID3/seek backoff等で数秒ズレやすいので、UIはタイマで進める。 uint32_t elapsed = elapsedBaseMs_; if (state_.nowPlaying.playing) { elapsed += (nowMs_ - trackStartedMs_); } if (state_.nowPlaying.durationMs > 0 && elapsed > state_.nowPlaying.durationMs) { elapsed = state_.nowPlaying.durationMs; } return elapsed; } // ---- Icons & marquee ---- static void drawIcon8_(U8G2 &g, uint8_t x, uint8_t yTop, const uint8_t *xbmp8) { if (!xbmp8) return; g.drawXBMP(x, yTop, 8, 8, xbmp8); } void updateBattery_() { if ((nowMs_ - lastBatteryMs_) < kBatteryPollMs_) return; lastBatteryMs_ = nowMs_; // LowPower.getVoltage() returns mV (Spresense) batteryMv_ = LowPower.getVoltage(); // シリアル出力(デバッグ用) Serial.print("Battery: "); Serial.print(batteryMv_); } const uint8_t *batteryIcon_() const { // 未接続(0mV想定)はempty if (batteryMv_ == 0) return kIconBatteryEmpty; if (batteryMv_ >= kBatteryFullMv_) return kIconBatteryFull; if (batteryMv_ >= kBatteryHalfMv_) return kIconBatteryHalf; if (batteryMv_ >= kBatteryLowMv_) return kIconBatteryLow; return kIconBatteryEmpty; } // ---- Persisted settings (EEPROM) ---- struct PersistSettings { uint32_t magic; uint16_t version; uint8_t volumePercent; uint8_t shuffle; uint8_t loop; uint8_t reserved[5]; uint32_t checksum; }; static constexpr uint32_t kPersistMagic_ = 0x53505253; // 'SPRS' static constexpr uint16_t kPersistVersion_ = 1; static constexpr int kPersistAddrSettings_ = 0; static uint32_t fnv1a32_(const uint8_t *data, size_t len) { uint32_t h = 2166136261u; if (!data) return h; for (size_t i = 0; i < len; ++i) { h ^= data[i]; h *= 16777619u; } return h; } static uint32_t calcPersistChecksum_(const PersistSettings &s) { return fnv1a32_(reinterpret_cast<const uint8_t *>(&s), offsetof(PersistSettings, checksum)); } static bool isPersistValid_(const PersistSettings &s) { if (s.magic != kPersistMagic_) return false; if (s.version != kPersistVersion_) return false; if (s.volumePercent > 100) return false; const uint32_t expect = calcPersistChecksum_(s); return (s.checksum == expect); } bool eepromLayoutOK_() const { const uint16_t eelen = EEPROM.length(); return (kPersistAddrSettings_ + static_cast<int>(sizeof(PersistSettings)) <= eelen); } PersistSettings makeDefaultPersist_() const { PersistSettings s{}; s.magic = kPersistMagic_; s.version = kPersistVersion_; s.volumePercent = cfg_.defaultVolumePercent; s.shuffle = 0; s.loop = 0; s.checksum = 0; s.checksum = calcPersistChecksum_(s); return s; } void applyPersist_(const PersistSettings &s) { setVolumePercent_(s.volumePercent, false); shuffle_ = (s.shuffle != 0); loop_ = (s.loop != 0); state_.nowPlaying.shuffle = shuffle_; state_.nowPlaying.loop = loop_; } void initPersist_() { EEPROM.init(); if (!eepromLayoutOK_()) { applyPersist_(makeDefaultPersist_()); persistReady_ = false; return; } PersistSettings s{}; EEPROM.get(kPersistAddrSettings_, s); if (!isPersistValid_(s)) { s = makeDefaultPersist_(); EEPROM.put(kPersistAddrSettings_, s); } applyPersist_(s); persistReady_ = true; } void savePersist_() { if (!persistReady_) return; PersistSettings s{}; s.magic = kPersistMagic_; s.version = kPersistVersion_; s.volumePercent = volumePercent_; s.shuffle = shuffle_ ? 1 : 0; s.loop = loop_ ? 1 : 0; s.checksum = 0; s.checksum = calcPersistChecksum_(s); EEPROM.put(kPersistAddrSettings_, s); } static uint32_t hashText_(const char *s) { // Simple FNV-1a for change detection uint32_t h = 2166136261u; if (!s) return h; while (*s) { h ^= static_cast<uint8_t>(*s++); h *= 16777619u; } return h; } static void drawMarqueeUtf8_(U8G2 &g, uint8_t x, uint8_t yBase, uint8_t w, uint8_t h, const char *utf8, uint32_t nowMs, uint32_t startMs) { if (!utf8 || !*utf8 || w == 0) return; const int16_t textW = g.getUTF8Width(utf8); if (textW <= w) { g.drawUTF8(x, yBase, utf8); return; } // Scroll after a short hold static constexpr uint16_t kGapPx = 18; static constexpr uint16_t kHoldMs = 900; static constexpr uint16_t kSpeedPxPerSec = 24; const uint32_t t = (nowMs >= startMs) ? (nowMs - startMs) : 0; int32_t offset = 0; const int32_t period = textW + kGapPx; if (t > kHoldMs && period > 0) { offset = static_cast<int32_t>(((t - kHoldMs) * kSpeedPxPerSec) / 1000u) % period; } // Clip to region so right-side indicators don't get overdrawn. int16_t yTop = 0; int16_t yBottom = 0; const int16_t ascent = g.getAscent(); const int16_t descent = g.getDescent(); if (ascent == 0 && descent == 0) { yTop = (yBase > h) ? static_cast<int16_t>(yBase - h) : 0; yBottom = static_cast<int16_t>(yBase + 2); } else { // add margins to avoid clipping ascenders/descenders while scrolling yTop = static_cast<int16_t>(yBase) - ascent - 3; yBottom = static_cast<int16_t>(yBase) - descent + 1; } if (yTop < 0) yTop = 0; if (yBottom > 63) yBottom = 63; g.setClipWindow(x, static_cast<uint8_t>(yTop), static_cast<uint8_t>(x + w - 1), static_cast<uint8_t>(yBottom)); g.drawUTF8(static_cast<int16_t>(x) - offset, yBase, utf8); g.drawUTF8(static_cast<int16_t>(x) - offset + period, yBase, utf8); g.setMaxClipWindow(); } // 8x8 monochrome XBM (LSB first per byte) // NOTE: Defined in a .cpp to avoid undefined references on C++14 toolchains. static const uint8_t kIconMusic[8]; static const uint8_t kIconPerson[8]; static const uint8_t kIconPlay[8]; static const uint8_t kIconPause[8]; static const uint8_t kIconStop[8]; static const uint8_t kIconLoop[8]; static const uint8_t kIconShuffle[8]; static const uint8_t kIconUsb[8]; static const uint8_t kIconBatteryEmpty[8]; static const uint8_t kIconBatteryLow[8]; static const uint8_t kIconBatteryHalf[8]; static const uint8_t kIconBatteryFull[8]; static const uint8_t kIconSpeaker[8]; static const uint8_t kIconPlaylist[8]; static const uint8_t kIconArtist[8]; static const uint8_t kIconAlbum[8]; static const uint8_t kIconYear[8]; const AppConfig &cfg_; Buttons &buttons_; OledUi &oled_; AppState state_{}; uint32_t loadingSinceMs_ = 0; // --- Storage/DB --- SDClass sd_; spdb::SpdbV2Sdhci db_{sd_}; bool sdMounted_ = false; bool dbOpen_ = false; bool dbDumped_ = false; // --- Debug --- uint32_t lastSdLogMs_ = 0; bool sdLogMounted_ = false; // --- Audio --- AudioClass *audio_ = nullptr; File audioFile_; bool audioReady_ = false; bool audioActive_ = false; bool errEnd_ = false; // --- USB MSC --- bool usbMscActive_ = false; bool usbMscError_ = false; // --- Queue --- spdb::TrackId *queue_ = nullptr; spdb::TrackId *queueOriginal_ = nullptr; uint16_t queueCount_ = 0; uint16_t queueIndex_ = 0; bool loop_ = false; bool shuffle_ = false; char loadingMsg_[64] = {0}; // UI timing uint32_t nowMs_ = 0; uint32_t marqueeTitleStartMs_ = 0; uint32_t marqueeMetaStartMs_ = 0; uint32_t marqueeTitleKey_ = 0; uint32_t marqueeMetaKey_ = 0; uint32_t marqueePlStartMs_ = 0; uint32_t marqueePlKey_ = 0; uint32_t marqueeArtistStartMs_ = 0; uint32_t marqueeArtistKey_ = 0; uint32_t marqueeAlbumStartMs_ = 0; uint32_t marqueeAlbumKey_ = 0; // Show track index/total for a short time after track change static constexpr uint32_t kShowTrackIndexMs_ = 3000; uint32_t showTrackIndexUntilMs_ = 0; // UI playback time (avoid VBR/seek drift) uint32_t trackStartedMs_ = 0; uint32_t elapsedBaseMs_ = 0; // NowPlaying button handling static constexpr uint16_t kPlayLongPressMs_ = 650; static constexpr uint16_t kBackLongPressMs_ = 900; uint32_t playPressStartMs_ = 0; bool playLongFired_ = false; uint32_t backPressStartMs_ = 0; bool backLongFired_ = false; // Volume uint8_t volumePercent_ = 0; bool persistReady_ = false; // Accel latest (for screensaver) int16_t accelAx_ = 0; int16_t accelAy_ = 0; int16_t accelAz_ = 0; // --- Pause/Resume --- char currentPath_[192] = {0}; uint32_t pausedFilePos_ = 0; // --- Playlist listing --- static constexpr uint8_t kMaxPlaylists = 24; uint8_t playlistCount_ = 0; bool playlistScanned_ = false; char playlistNames_[kMaxPlaylists][48] = {{0}}; char playlistPaths_[kMaxPlaylists][64] = {{0}}; // --- Artist/Album/Year listing --- uint16_t artistCount_ = 0; uint16_t albumCount_ = 0; bool artistScanned_ = false; bool albumScanned_ = false; uint16_t *yearValues_ = nullptr; uint16_t yearCount_ = 0; bool yearsBuilt_ = false; // --- ScreenSaver (bubble) --- struct Bubble { float x; float y; float vx; float vy; uint8_t r; }; static constexpr uint8_t kBubbleCount_ = 14; Bubble bubbles_[kBubbleCount_] = {}; bool screensaverInit_ = false; bool screensaverShowInfo_ = false; float screensaverUpX_ = -1.0f; // screen-left as initial up float screensaverUpY_ = 0.0f; // --- Battery --- static constexpr uint32_t kBatteryPollMs_ = 5000; static constexpr uint16_t kBatteryFullMv_ = 3700; static constexpr uint16_t kBatteryHalfMv_ = 3500; static constexpr uint16_t kBatteryLowMv_ = 3300; uint32_t lastBatteryMs_ = 0; uint16_t batteryMv_ = 0; static void audio_attention_cb_(const ErrorAttentionParam *atprm) { if (!atprm) return; // インスタンスに届かないので、重めの処理はしない // ここでは何もしない(errEnd_はprocessAudio_の戻り値で拾う) } bool initAudio_() { audio_ = AudioClass::getInstance(); if (!audio_) return false; errEnd_ = false; audio_->begin(audio_attention_cb_); audio_->setRenderingClockMode(AS_CLKMODE_NORMAL); { const err_t modeErr = audio_->setPlayerMode(AS_SETPLAYER_OUTPUTDEVICE_SPHP, AS_SP_DRV_MODE_LINEOUT, kAudioPlayer0FifoBytes_, kAudioPlayer1FifoBytes_); if (modeErr != AUDIOLIB_ECODE_OK) { Serial.print("[Audio] setPlayerMode failed err="); Serial.println(static_cast<int>(modeErr)); return false; } } err_t err = audio_->initPlayer(AudioClass::Player0, AS_CODECTYPE_MP3, player::kDecoderDir, AS_SAMPLINGRATE_AUTO, AS_CHANNEL_STEREO); if (err != AUDIOLIB_ECODE_OK) { return false; } // Apply persisted (or default) volume setVolumePercent_(volumePercent_, true); return true; } bool ensureSdMounted_() { if (sdMounted_) return true; if (sd_.begin()) { sdMounted_ = true; return true; } return false; } bool reinitPlayer_() { if (!audio_) return false; // Reset audio manager state (this frees FIFO buffers/pools inside the library). { const err_t readyErr = audio_->setReadyMode(); if (readyErr != AUDIOLIB_ECODE_OK) { Serial.print("[Audio] setReadyMode failed err="); Serial.println(static_cast<int>(readyErr)); // Continue anyway; we can still try reconfiguring. } } // Re-allocate FIFO buffers with safer sizes. { const err_t modeErr = audio_->setPlayerMode(AS_SETPLAYER_OUTPUTDEVICE_SPHP, AS_SP_DRV_MODE_LINEOUT, kAudioPlayer0FifoBytes_, kAudioPlayer1FifoBytes_); if (modeErr != AUDIOLIB_ECODE_OK) { Serial.print("[Audio] setPlayerMode(retry) failed err="); Serial.println(static_cast<int>(modeErr)); return false; } } // Re-init decoder. { const err_t initErr = audio_->initPlayer(AudioClass::Player0, AS_CODECTYPE_MP3, player::kDecoderDir, AS_SAMPLINGRATE_AUTO, AS_CHANNEL_STEREO); if (initErr != AUDIOLIB_ECODE_OK) { Serial.print("[Audio] initPlayer(retry) failed err="); Serial.println(static_cast<int>(initErr)); return false; } } // Keep volume consistent after re-init setVolumePercent_(volumePercent_, true); return true; } bool openPrimeAndStart_(const char *path, uint32_t seekPos, const char *failMsg) { if (!path || !*path) return false; if (!audio_ || !audioReady_) return false; if (!ensureSdMounted_()) return false; audioFile_ = sd_.open(path); if (!audioFile_) { goError_("音声ファイルを開けません"); return false; } if (seekPos > 0) { (void)audioFile_.seek(seekPos); } err_t err = audio_->writeFrames(AudioClass::Player0, audioFile_); if ((err != AUDIOLIB_ECODE_OK) && (err != AUDIOLIB_ECODE_FILEEND)) { Serial.print("[Audio] writeFrames failed err="); Serial.print(static_cast<int>(err)); Serial.print(" path='"); Serial.print(path); Serial.print("' size="); Serial.print(static_cast<uint32_t>(audioFile_.size())); Serial.print(" pos="); Serial.println(static_cast<uint32_t>(audioFile_.position())); audioFile_.close(); goError_(failMsg ? failMsg : "ファイル読込エラー"); return false; } if (err == AUDIOLIB_ECODE_FILEEND) { audioFile_.close(); goError_("ファイル終端(空?)"); return false; } audio_->startPlayer(AudioClass::Player0); audioActive_ = true; return true; } void stopAudio_() { if (!audioReady_ || !audio_) return; if (audioActive_) { // stopPlayer(id) だとバッファが空になるまで待って停止する挙動になり、 // 次曲ロード/疑似pauseが遅く感じることがあるため、明示モードで停止する。 audio_->stopPlayer(AudioClass::Player0, AS_STOPPLAYER_NORMAL); audioActive_ = false; } if (audioFile_) { audioFile_.close(); } currentPath_[0] = '\0'; pausedFilePos_ = 0; // Reset UI timer elapsedBaseMs_ = 0; trackStartedMs_ = nowMs_; } void beginUsbMsc_() { usbMscError_ = false; usbMscActive_ = false; // Stop audio and release SD file before switching to MSC. stopAudio_(); state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; if (!sdMounted_) { if (!sd_.begin()) { usbMscError_ = true; return; } sdMounted_ = true; } if (sd_.beginUsbMsc()) { usbMscError_ = true; return; } usbMscActive_ = true; } void endUsbMsc_() { if (usbMscActive_) { if (sd_.endUsbMsc()) { usbMscError_ = true; } } usbMscActive_ = false; } void freeQueue_() { delete[] queue_; queue_ = nullptr; delete[] queueOriginal_; queueOriginal_ = nullptr; queueCount_ = 0; queueIndex_ = 0; } void processAudio_() { if (!state_.nowPlaying.playing) return; if (!audioReady_ || !audio_ || !audioActive_ || !audioFile_) return; const int err = audio_->writeFrames(AudioClass::Player0, audioFile_); if (err == AUDIOLIB_ECODE_FILEEND) { nextTrack_(); return; } if (err) { stopAudio_(); goError_("Audio再生エラー"); return; } if (errEnd_) { stopAudio_(); goError_("Audio内部エラー"); return; } } static void normalizeMediaPath_(const char *in, char *out, size_t outSize) { if (!out || outSize == 0) return; out[0] = '\0'; if (!in) return; // SDHCI.open()は相対パス想定。 // - "/MUSIC/..." を返す場合 → 先頭'/'除去 // - "/mnt/sd0/MUSIC/..." を返す場合 → "mnt/sd0/" も除去 const char *p = in; while (*p == '/') ++p; // "mnt/sd0/" prefix static const char kMntPrefix[] = "mnt/sd0/"; if (strncmp(p, kMntPrefix, sizeof(kMntPrefix) - 1) == 0) { p += (sizeof(kMntPrefix) - 1); } strncpy(out, p, outSize - 1); out[outSize - 1] = '\0'; } // ---- Buttons helpers ---- bool pressed_(uint8_t idx) const { return buttons_.pressed(idx); } bool rose_(uint8_t idx) const { return buttons_.rose(idx); } int8_t indexByPin_(uint8_t pin) const { for (uint8_t i = 0; i < 6; ++i) { if (cfg_.buttonPins[i] == pin) return static_cast<int8_t>(i); } return -1; } // ---- Volume ---- static uint8_t clampU8_(int v, uint8_t lo, uint8_t hi) { if (v < lo) return lo; if (v > hi) return hi; return static_cast<uint8_t>(v); } int volumeFromPercent_(uint8_t percent) const { const int minV = cfg_.minMasterVolume; const int maxV = cfg_.maxMasterVolume; const int lo = (minV < maxV) ? minV : maxV; const int hi = (minV < maxV) ? maxV : minV; const int32_t span = static_cast<int32_t>(hi - lo); int32_t v = static_cast<int32_t>(lo) + (static_cast<int32_t>(percent) * span) / 100; if (v < lo) v = lo; if (v > hi) v = hi; return static_cast<int>(v); } void setVolumePercent_(uint8_t percent, bool applyToAudio) { const uint8_t prev = volumePercent_; volumePercent_ = clampU8_(percent, 0, 100); state_.nowPlaying.volumePercent = volumePercent_; // audioReady_が立つ前(initAudio_中)でもsetVolumeできるため、audio_があれば適用する。 if (applyToAudio && audio_) { audio_->setVolume(volumeFromPercent_(volumePercent_)); } if (persistReady_ && prev != volumePercent_) { savePersist_(); } } void volumeStep_(int8_t stepPercent) { const int next = static_cast<int>(volumePercent_) + static_cast<int>(stepPercent); setVolumePercent_(clampU8_(next, 0, 100), true); } void cycleShuffleLoop_() { // Off -> Shuffle -> Loop -> Shuffle+Loop -> Off if (!shuffle_ && !loop_) { setShuffle_(true); loop_ = false; } else if (shuffle_ && !loop_) { setShuffle_(false); loop_ = true; } else if (!shuffle_ && loop_) { setShuffle_(true); loop_ = true; } else { setShuffle_(false); loop_ = false; } state_.nowPlaying.loop = loop_; state_.nowPlaying.shuffle = shuffle_; if (persistReady_) { savePersist_(); } } static void stripTrailingSlashes_(char *s) { if (!s) return; size_t n = strlen(s); while (n > 0) { const char c = s[n - 1]; if (c == '/' || c == '\\') { s[n - 1] = '\0'; --n; continue; } break; } } void debugListDir_(const char *dirName, uint8_t maxEntries, uint8_t subDirMaxEntries = 0) { if (!dirName) return; if (!ensureSdMounted_()) return; File dir = sd_.open(dirName); if (!dir && (dirName[0] == '/' && dirName[1] == '\0')) { // 実装差異でrootを""として扱うケースの保険 dir = sd_.open(""); } Serial.print("[SD] dir '"); Serial.print(dirName); Serial.print("' => "); if (!dir) { Serial.println("OPEN FAILED"); return; } if (!dir.isDirectory()) { Serial.println("NOT A DIR"); dir.close(); return; } Serial.println("OK"); uint8_t shown = 0; while (shown < maxEntries) { File entry = dir.openNextFile(); if (!entry) break; const char *name = entry.name(); Serial.print("[SD] - "); Serial.print(name ? name : "(null)"); if (entry.isDirectory()) { Serial.println("/"); } else { Serial.print(" (size="); Serial.print((uint32_t)entry.size()); Serial.println(")"); } // entry.name() が絶対パスを返す場合があるので、正規化した上でopenできるかも確認 if (name && *name) { char norm[256] = {}; normalizeMediaPath_(name, norm, sizeof(norm)); stripTrailingSlashes_(norm); if (norm[0]) { Serial.print("[SD] openTest '"); Serial.print(norm); Serial.print("' => "); Serial.println(debugTryOpenPathVariants_(norm) ? "OK" : "FAILED"); } } // サブディレクトリの中身も少しだけ列挙 if (subDirMaxEntries > 0 && entry.isDirectory()) { uint8_t subShown = 0; while (subShown < subDirMaxEntries) { File sub = entry.openNextFile(); if (!sub) break; const char *subName = sub.name(); Serial.print("[SD] * "); Serial.print(subName ? subName : "(null)"); if (sub.isDirectory()) { Serial.println("/"); } else { Serial.print(" (size="); Serial.print((uint32_t)sub.size()); Serial.println(")"); } sub.close(); ++subShown; } } entry.close(); ++shown; } dir.close(); } bool debugTryOpenPathVariants_(const char *relativePath) { if (!relativePath || !*relativePath) return false; if (!ensureSdMounted_()) return false; // 1) 相対 { File f = sd_.open(relativePath); if (f) { f.close(); return true; } } // 2) 先頭 '/' char abs1[224] = {}; snprintf(abs1, sizeof(abs1), "/%s", relativePath); { File f = sd_.open(abs1); if (f) { f.close(); return true; } } // 3) "/mnt/sd0/" を付ける char abs2[256] = {}; snprintf(abs2, sizeof(abs2), "/mnt/sd0/%s", relativePath); { File f = sd_.open(abs2); if (f) { f.close(); return true; } } return false; } void dumpDbSample_() { if (!dbOpen_) return; spdb::Header hdr{}; if (!db_.readHeader(hdr)) { Serial.println("[DB] readHeader failed"); return; } Serial.println("[DB] ---- library.bin sample ----"); Serial.print("[DB] path="); Serial.println(cfg_.dbPath ? cfg_.dbPath : player::kDbPath); Serial.print("[DB] artists="); Serial.print(hdr.artist_count); Serial.print(" albums="); Serial.print(hdr.album_count); Serial.print(" tracks="); Serial.println(hdr.track_count); if (cfg_.debugListRootDirOnBoot) { debugListDir_("/", cfg_.debugListRootDirMaxEntries); } if (cfg_.debugListMusicDirOnBoot) { debugListDir_("MUSIC", cfg_.debugListMusicDirMaxEntries, cfg_.debugListMusicSubDirsOnBoot ? cfg_.debugListMusicSubDirMaxEntries : 0); } uint16_t n = hdr.track_count; uint8_t want = cfg_.debugDumpDbSampleCount; if (want == 0) want = 1; if (n < want) want = static_cast<uint8_t>(n); for (uint8_t i = 0; i < want; ++i) { const spdb::TrackId id = static_cast<spdb::TrackId>(i); spdb::TrackRec tr{}; spdb::Status st = db_.readTrack(id, tr); if (!st) { Serial.print("[DB] track "); Serial.print(id); Serial.println(" readTrack failed"); continue; } char title[64] = {}; (void)db_.readString(tr.title, title, sizeof(title)); char raw[192] = {}; st = db_.readString(tr.path, raw, sizeof(raw)); if (!st) { Serial.print("[DB] track "); Serial.print(id); Serial.println(" readString(path) failed"); continue; } char norm[192] = {}; normalizeMediaPath_(raw, norm, sizeof(norm)); Serial.print("[DB] #"); Serial.print(id); Serial.print(" title='"); Serial.print(title); Serial.print("' pathRaw='"); Serial.print(raw); Serial.print("' path='"); Serial.print(norm); Serial.println("'"); if (cfg_.debugTryOpenTrackPathsOnBoot) { Serial.print("[DB] open => "); if (debugTryOpenPathVariants_(norm)) { Serial.println("OK (one of variants)"); } else { Serial.println("FAILED (all variants)"); } } } Serial.println("[DB] ---- end ----"); } bool startTrackById_(spdb::TrackId trackId) { if (!dbOpen_) { goError_("DB未オープン"); return false; } if (!audioReady_) { goError_("Audio未初期化"); return false; } spdb::TrackRec tr{}; spdb::Status st = db_.readTrack(trackId, tr); if (!st) { goError_("Track読込失敗"); return false; } char pathRaw[192] = {}; st = db_.readString(tr.path, pathRaw, sizeof(pathRaw)); if (!st) { goError_("Path読込失敗"); return false; } char pathBuf[192] = {}; normalizeMediaPath_(pathRaw, pathBuf, sizeof(pathBuf)); char titleBuf[sizeof(state_.nowPlaying.title)] = {}; (void)db_.readString(tr.title, titleBuf, sizeof(titleBuf)); // Artist/Album for UI char artistBuf[sizeof(state_.nowPlaying.artist)] = {}; { spdb::ArtistRec ar{}; if (db_.readArtist(tr.artist_id, ar)) { (void)db_.readString(ar.name, artistBuf, sizeof(artistBuf)); } } char albumBuf[sizeof(state_.nowPlaying.album)] = {}; { spdb::AlbumRec al{}; if (db_.readAlbum(tr.album_id, al)) { (void)db_.readString(al.name, albumBuf, sizeof(albumBuf)); } } stopAudio_(); strncpy(currentPath_, pathBuf, sizeof(currentPath_) - 1); currentPath_[sizeof(currentPath_) - 1] = '\0'; pausedFilePos_ = 0; if (!openPrimeAndStart_(pathBuf, 0, "ファイル読込エラー")) { // Some errors are recoverable by reinitializing the player buffer state. // Attempt once to recover, then retry. Serial.println("[Audio] try recover: stopPlayer + initPlayer + retry"); audio_->stopPlayer(AudioClass::Player0, AS_STOPPLAYER_NORMAL); audioActive_ = false; if (!reinitPlayer_()) { goError_("Audio再初期化失敗"); return false; } if (!openPrimeAndStart_(pathBuf, 0, "ファイル読込エラー")) { return false; } } // UI timer reset on track start elapsedBaseMs_ = 0; trackStartedMs_ = nowMs_; if (state_.nowPlaying.queueCount >= 100) { showTrackIndexUntilMs_ = nowMs_ + kShowTrackIndexMs_; } else { showTrackIndexUntilMs_ = 0; } strncpy(state_.nowPlaying.title, titleBuf, sizeof(state_.nowPlaying.title) - 1); state_.nowPlaying.title[sizeof(state_.nowPlaying.title) - 1] = '\0'; strncpy(state_.nowPlaying.artist, artistBuf, sizeof(state_.nowPlaying.artist) - 1); state_.nowPlaying.artist[sizeof(state_.nowPlaying.artist) - 1] = '\0'; strncpy(state_.nowPlaying.album, albumBuf, sizeof(state_.nowPlaying.album) - 1); state_.nowPlaying.album[sizeof(state_.nowPlaying.album) - 1] = '\0'; state_.nowPlaying.durationMs = tr.duration_ms; return true; } bool startCurrentTrack_() { if (!queue_ || queueCount_ == 0 || queueIndex_ >= queueCount_) { goError_("キューが空です"); return false; } return startTrackById_(queue_[queueIndex_]); } void nextTrack_() { if (!queue_ || queueCount_ == 0) return; if (queueIndex_ + 1 < queueCount_) { ++queueIndex_; } else { if (!loop_) { stopAudio_(); state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; return; } queueIndex_ = 0; } state_.nowPlaying.queueIndex = queueIndex_; if (!startCurrentTrack_()) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; return; } // 一時停止中に次/前の曲を押した場合は自動で再生にシフト state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; } void prevTrack_() { if (!queue_ || queueCount_ == 0) return; if (queueIndex_ > 0) { --queueIndex_; } else { if (!loop_) { return; } queueIndex_ = queueCount_ - 1; } state_.nowPlaying.queueIndex = queueIndex_; if (!startCurrentTrack_()) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; return; } // 一時停止中に次/前の曲を押した場合は自動で再生にシフト state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; } void shuffleQueue_() { if (!queue_ || queueCount_ <= 1) return; for (int i = queueCount_ - 1; i > 0; --i) { const int j = random(i + 1); const spdb::TrackId tmp = queue_[i]; queue_[i] = queue_[j]; queue_[j] = tmp; } } static void copyTrackIds_(spdb::TrackId *dst, const spdb::TrackId *src, uint16_t count) { if (!dst || !src) return; memcpy(dst, src, static_cast<size_t>(count) * sizeof(spdb::TrackId)); } int16_t findInOriginal_(spdb::TrackId id) const { if (!queueOriginal_) return -1; for (uint16_t i = 0; i < queueCount_; ++i) { if (queueOriginal_[i] == id) return static_cast<int16_t>(i); } return -1; } void setShuffle_(bool enable) { if (shuffle_ == enable) return; shuffle_ = enable; state_.nowPlaying.shuffle = shuffle_; if (!queue_ || queueCount_ == 0) return; if (!queueOriginal_) return; const spdb::TrackId current = (queueIndex_ < queueCount_) ? queue_[queueIndex_] : queue_[0]; // まず元順序に復帰 copyTrackIds_(queue_, queueOriginal_, queueCount_); if (enable) { // currentを先頭固定し、残りをシャッフル const int16_t pos = findInOriginal_(current); if (pos >= 0) { queue_[0] = current; uint16_t w = 1; for (uint16_t i = 0; i < queueCount_; ++i) { if (i == static_cast<uint16_t>(pos)) continue; queue_[w++] = queueOriginal_[i]; } for (int i = queueCount_ - 1; i > 1; --i) { const int j = 1 + random(i); const spdb::TrackId tmp = queue_[i]; queue_[i] = queue_[j]; queue_[j] = tmp; } queueIndex_ = 0; } else { shuffleQueue_(); queueIndex_ = 0; } } else { const int16_t pos = findInOriginal_(current); queueIndex_ = (pos >= 0) ? static_cast<uint16_t>(pos) : 0; } state_.nowPlaying.queueIndex = queueIndex_; } void pause_() { if (!state_.nowPlaying.playing) return; if (!audio_ || !audioFile_) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; return; } // Capture elapsed for UI before stopping elapsedBaseMs_ = estimateElapsedMs_(); pausedFilePos_ = audioFile_.position(); audio_->stopPlayer(AudioClass::Player0, AS_STOPPLAYER_NORMAL); audioActive_ = false; audioFile_.close(); state_.nowPlaying.playing = false; state_.nowPlaying.paused = true; } void resume_() { if (!state_.nowPlaying.paused) return; if (!audioReady_ || !audio_ || currentPath_[0] == '\0') { goError_("再開できません"); state_.nowPlaying.paused = false; return; } constexpr uint32_t kBackoff = 2048; uint32_t pos = pausedFilePos_; pos = (pos > kBackoff) ? (pos - kBackoff) : 0; if (!openPrimeAndStart_(currentPath_, pos, "再開読込エラー")) { Serial.println("[Audio] try recover(resume): stopPlayer + initPlayer + retry"); audio_->stopPlayer(AudioClass::Player0, AS_STOPPLAYER_NORMAL); audioActive_ = false; if (!reinitPlayer_()) { goError_("Audio再初期化失敗"); state_.nowPlaying.paused = false; return; } if (!openPrimeAndStart_(currentPath_, pos, "再開読込エラー")) { state_.nowPlaying.paused = false; return; } } // Continue UI timer from accumulated value trackStartedMs_ = nowMs_; state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; } bool startAllTracks_() { if (!dbOpen_) { goError_("DB未オープン"); return false; } freeQueue_(); const uint16_t n = db_.trackCount(); if (n == 0) { goError_("DBに曲がありません"); return false; } queue_ = new spdb::TrackId[n]; queueCount_ = n; for (uint16_t i = 0; i < n; ++i) { queue_[i] = static_cast<spdb::TrackId>(i); } queueOriginal_ = new spdb::TrackId[n]; copyTrackIds_(queueOriginal_, queue_, queueCount_); queueIndex_ = 0; state_.nowPlaying.queueCount = queueCount_; state_.nowPlaying.queueIndex = queueIndex_; state_.nowPlaying.loop = loop_; state_.nowPlaying.shuffle = shuffle_; strncpy(state_.nowPlaying.source, "ALL", sizeof(state_.nowPlaying.source) - 1); state_.nowPlaying.source[sizeof(state_.nowPlaying.source) - 1] = '\0'; if (shuffle_) { setShuffle_(true); } if (!startCurrentTrack_()) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; return false; } state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; go_(ScreenId::NowPlaying); return true; } bool startPlaylist_(const char *plbPath, const char *plbName) { if (!plbPath || !*plbPath) { goError_("PLBパス不正"); return false; } freeQueue_(); spdb::PlbV1Sdhci plb(sd_); spdb::Status st = plb.open(plbPath); if (!st) { Serial.print("[PLB] open failed path='"); Serial.print(plbPath); Serial.print("' code="); Serial.print(static_cast<uint8_t>(st.code)); Serial.print(" detail="); Serial.println(st.detail); // openに失敗した場合、パスの候補(相対/先頭'/'/mnt)も試して原因切り分け char norm[256] = {}; normalizeMediaPath_(plbPath, norm, sizeof(norm)); stripTrailingSlashes_(norm); if (norm[0]) { Serial.print("[PLB] openTest '"); Serial.print(norm); Serial.print("' => "); Serial.println(debugTryOpenPathVariants_(norm) ? "OK" : "FAILED"); } if (st.code == spdb::ErrorCode::IoError) { goError_("PLBファイルを開けません"); } else if (st.code == spdb::ErrorCode::FormatError) { goError_("PLB形式エラー"); } else if (st.code == spdb::ErrorCode::Unsupported) { goError_("PLBバージョン非対応"); } else { goError_("PLB open失敗"); } return false; } uint32_t cnt = 0; st = plb.count(cnt); if (!st || cnt == 0 || cnt > 65535u) { goError_("PLB count不正"); return false; } queue_ = new spdb::TrackId[static_cast<uint16_t>(cnt)]; queueCount_ = static_cast<uint16_t>(cnt); (void)plb.reset(); for (uint16_t i = 0; i < queueCount_; ++i) { spdb::TrackId id = 0; st = plb.next(id); if (!st) { goError_("PLB read失敗"); freeQueue_(); return false; } queue_[i] = id; } queueOriginal_ = new spdb::TrackId[queueCount_]; copyTrackIds_(queueOriginal_, queue_, queueCount_); queueIndex_ = 0; state_.nowPlaying.queueCount = queueCount_; state_.nowPlaying.queueIndex = queueIndex_; state_.nowPlaying.loop = loop_; state_.nowPlaying.shuffle = shuffle_; strncpy(state_.nowPlaying.source, "PLB", sizeof(state_.nowPlaying.source) - 1); state_.nowPlaying.source[sizeof(state_.nowPlaying.source) - 1] = '\0'; // プレイリスト名をタイトル先頭に一瞬出したい場合はここで加工できる (void)plbName; if (shuffle_) { setShuffle_(true); } if (!startCurrentTrack_()) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; return false; } state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; go_(ScreenId::NowPlaying); return true; } bool startArtist_(spdb::ArtistId artistId) { if (!dbOpen_) { goError_("DB未オープン"); return false; } freeQueue_(); spdb::ArtistRec ar{}; if (!db_.readArtist(artistId, ar)) { goError_("Artist読込失敗"); return false; } const uint16_t albumCount = ar.album_link_count; if (albumCount == 0) { goError_("Artistに曲がありません"); return false; } spdb::AlbumId *albumIds = new spdb::AlbumId[albumCount]; uint16_t got = 0; spdb::Status st = db_.artistAlbums(artistId, albumIds, albumCount, got); if (!st || got == 0) { delete[] albumIds; goError_("Artistアルバム取得失敗"); return false; } uint32_t totalTracks = 0; for (uint16_t i = 0; i < got; ++i) { spdb::AlbumRec al{}; if (db_.readAlbum(albumIds[i], al)) { totalTracks += al.track_link_count; } } if (totalTracks == 0 || totalTracks > 65535u) { delete[] albumIds; goError_("Artist曲数不正"); return false; } queue_ = new spdb::TrackId[static_cast<uint16_t>(totalTracks)]; queueCount_ = static_cast<uint16_t>(totalTracks); uint16_t pos = 0; for (uint16_t i = 0; i < got; ++i) { spdb::AlbumRec al{}; if (!db_.readAlbum(albumIds[i], al) || al.track_link_count == 0) { continue; } const uint16_t cnt = al.track_link_count; spdb::TrackId *tmp = new spdb::TrackId[cnt]; uint16_t outCnt = 0; st = db_.albumTracks(albumIds[i], tmp, cnt, outCnt); if (st && outCnt > 0) { for (uint16_t k = 0; k < outCnt && pos < queueCount_; ++k) { queue_[pos++] = tmp[k]; } } delete[] tmp; } delete[] albumIds; if (pos == 0) { freeQueue_(); goError_("Artist曲取得失敗"); return false; } queueCount_ = pos; queueOriginal_ = new spdb::TrackId[queueCount_]; copyTrackIds_(queueOriginal_, queue_, queueCount_); queueIndex_ = 0; state_.nowPlaying.queueCount = queueCount_; state_.nowPlaying.queueIndex = queueIndex_; state_.nowPlaying.loop = loop_; state_.nowPlaying.shuffle = shuffle_; strncpy(state_.nowPlaying.source, "ART", sizeof(state_.nowPlaying.source) - 1); state_.nowPlaying.source[sizeof(state_.nowPlaying.source) - 1] = '\0'; if (shuffle_) { setShuffle_(true); } if (!startCurrentTrack_()) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; return false; } state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; go_(ScreenId::NowPlaying); return true; } bool startAlbum_(spdb::AlbumId albumId) { if (!dbOpen_) { goError_("DB未オープン"); return false; } freeQueue_(); spdb::AlbumRec al{}; if (!db_.readAlbum(albumId, al)) { goError_("Album読込失敗"); return false; } if (al.track_link_count == 0) { goError_("Albumに曲がありません"); return false; } const uint16_t cnt = al.track_link_count; queue_ = new spdb::TrackId[cnt]; queueCount_ = cnt; uint16_t outCnt = 0; spdb::Status st = db_.albumTracks(albumId, queue_, cnt, outCnt); if (!st || outCnt == 0) { freeQueue_(); goError_("Album曲取得失敗"); return false; } queueCount_ = outCnt; queueOriginal_ = new spdb::TrackId[queueCount_]; copyTrackIds_(queueOriginal_, queue_, queueCount_); queueIndex_ = 0; state_.nowPlaying.queueCount = queueCount_; state_.nowPlaying.queueIndex = queueIndex_; state_.nowPlaying.loop = loop_; state_.nowPlaying.shuffle = shuffle_; strncpy(state_.nowPlaying.source, "ALB", sizeof(state_.nowPlaying.source) - 1); state_.nowPlaying.source[sizeof(state_.nowPlaying.source) - 1] = '\0'; if (shuffle_) { setShuffle_(true); } if (!startCurrentTrack_()) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; return false; } state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; go_(ScreenId::NowPlaying); return true; } bool startYear_(uint16_t year) { if (!dbOpen_) { goError_("DB未オープン"); return false; } if (year == 0) { goError_("年不正"); return false; } freeQueue_(); const uint16_t n = db_.trackCount(); if (n == 0) { goError_("DBに曲がありません"); return false; } uint16_t count = 0; spdb::TrackRec tr{}; for (uint16_t i = 0; i < n; ++i) { if (!db_.readTrack(static_cast<spdb::TrackId>(i), tr)) continue; if (tr.track_year == year) { ++count; } } if (count == 0) { goError_("該当年の曲がありません"); return false; } queue_ = new spdb::TrackId[count]; queueCount_ = count; uint16_t pos = 0; for (uint16_t i = 0; i < n && pos < count; ++i) { if (!db_.readTrack(static_cast<spdb::TrackId>(i), tr)) continue; if (tr.track_year == year) { queue_[pos++] = static_cast<spdb::TrackId>(i); } } if (pos == 0) { freeQueue_(); goError_("年別曲取得失敗"); return false; } queueCount_ = pos; queueOriginal_ = new spdb::TrackId[queueCount_]; copyTrackIds_(queueOriginal_, queue_, queueCount_); queueIndex_ = 0; state_.nowPlaying.queueCount = queueCount_; state_.nowPlaying.queueIndex = queueIndex_; state_.nowPlaying.loop = loop_; state_.nowPlaying.shuffle = shuffle_; strncpy(state_.nowPlaying.source, "YEAR", sizeof(state_.nowPlaying.source) - 1); state_.nowPlaying.source[sizeof(state_.nowPlaying.source) - 1] = '\0'; if (shuffle_) { setShuffle_(true); } if (!startCurrentTrack_()) { state_.nowPlaying.playing = false; state_.nowPlaying.paused = false; return false; } state_.nowPlaying.playing = true; state_.nowPlaying.paused = false; go_(ScreenId::NowPlaying); return true; } void freeYearList_() { if (yearValues_) { delete[] yearValues_; } yearValues_ = nullptr; yearCount_ = 0; } void buildYearList_() { freeYearList_(); if (!dbOpen_) return; const uint16_t n = db_.trackCount(); if (n == 0) return; uint16_t *tmp = new uint16_t[n]; uint16_t cnt = 0; spdb::TrackRec tr{}; for (uint16_t i = 0; i < n; ++i) { if (!db_.readTrack(static_cast<spdb::TrackId>(i), tr)) continue; const uint16_t y = tr.track_year; if (y == 0) continue; bool exists = false; for (uint16_t j = 0; j < cnt; ++j) { if (tmp[j] == y) { exists = true; break; } } if (!exists) { tmp[cnt++] = y; } } if (cnt == 0) { delete[] tmp; return; } // simple insertion sort ascending for (uint16_t i = 1; i < cnt; ++i) { uint16_t key = tmp[i]; int j = i - 1; while (j >= 0 && tmp[j] > key) { tmp[j + 1] = tmp[j]; --j; } tmp[j + 1] = key; } yearValues_ = new uint16_t[cnt]; for (uint16_t i = 0; i < cnt; ++i) { yearValues_[i] = tmp[i]; } yearCount_ = cnt; delete[] tmp; } bool loadPlaylistsMeta_() { playlistCount_ = 0; if (!ensureSdMounted_()) return false; const char *metaPath = cfg_.playlistsMetaPath; if (!metaPath || !*metaPath) metaPath = player::kPlaylistsMetaPath; spdb::PlmV1Sdhci plm(sd_); spdb::Status st = plm.open(metaPath); if (!st) { return false; } uint32_t cnt = 0; st = plm.count(cnt); if (!st || cnt == 0) { return false; } const uint32_t limit = (cnt > kMaxPlaylists) ? kMaxPlaylists : cnt; // PLAYLISTS dir name const char *dirName = cfg_.playlistsDir; if (!dirName || !*dirName) dirName = "PLAYLISTS"; char dirBuf[64] = {}; strncpy(dirBuf, dirName, sizeof(dirBuf) - 1); dirBuf[sizeof(dirBuf) - 1] = '\0'; stripTrailingSlashes_(dirBuf); if (dirBuf[0] == '\0') { strncpy(dirBuf, "PLAYLISTS", sizeof(dirBuf) - 1); dirBuf[sizeof(dirBuf) - 1] = '\0'; } for (uint32_t i = 0; i < limit; ++i) { spdb::PlaylistMetaRec rec{}; st = plm.readMeta(i, rec); if (!st) continue; char nameBuf[64] = {}; if (!plm.readString(rec.name, nameBuf, sizeof(nameBuf))) continue; char plbBuf[96] = {}; if (!plm.readString(rec.plb_path, plbBuf, sizeof(plbBuf))) continue; // normalize plb path char plbNorm[128] = {}; normalizeMediaPath_(plbBuf, plbNorm, sizeof(plbNorm)); stripTrailingSlashes_(plbNorm); if (!plbNorm[0]) continue; strncpy(playlistNames_[playlistCount_], nameBuf, sizeof(playlistNames_[0]) - 1); playlistNames_[playlistCount_][sizeof(playlistNames_[0]) - 1] = '\0'; // if plb path has no slash, prefix PLAYLISTS/ if (strchr(plbNorm, '/') == nullptr) { snprintf(playlistPaths_[playlistCount_], sizeof(playlistPaths_[0]), "%s/%s", dirBuf, plbNorm); } else { strncpy(playlistPaths_[playlistCount_], plbNorm, sizeof(playlistPaths_[0]) - 1); playlistPaths_[playlistCount_][sizeof(playlistPaths_[0]) - 1] = '\0'; } ++playlistCount_; if (playlistCount_ >= kMaxPlaylists) break; } return playlistCount_ > 0; } void scanPlaylists_() { playlistCount_ = 0; if (!ensureSdMounted_()) return; // Prefer metadata-driven playlist list (DB/playlists.bin) if (loadPlaylistsMeta_()) { return; } // 末尾/有無どちらでも動くようにする const char *dirName = cfg_.playlistsDir; if (!dirName || !*dirName) dirName = "PLAYLISTS"; // 末尾のスラッシュ有無を吸収 char dirBuf[64] = {}; strncpy(dirBuf, dirName, sizeof(dirBuf) - 1); dirBuf[sizeof(dirBuf) - 1] = '\0'; stripTrailingSlashes_(dirBuf); if (dirBuf[0] == '\0') { strncpy(dirBuf, "PLAYLISTS", sizeof(dirBuf) - 1); dirBuf[sizeof(dirBuf) - 1] = '\0'; } File dir = sd_.open(dirBuf); if (!dir) { return; } // Arduino SD互換: openNextFile() while (true) { File entry = dir.openNextFile(); if (!entry) break; if (entry.isDirectory()) { entry.close(); continue; } const char *nameRaw = entry.name(); if (!nameRaw || !*nameRaw) { entry.close(); continue; } // entry.name() が "/mnt/sd0/PLAYLISTS/foo.plb" のような絶対パスを返すことがある。 // SDHCI.open() は相対パス想定なので、正規化して basename のみ取り出す。 char nameNorm[256] = {}; normalizeMediaPath_(nameRaw, nameNorm, sizeof(nameNorm)); stripTrailingSlashes_(nameNorm); const char *base = nameNorm; const char *lastSlash = strrchr(nameNorm, '/'); if (lastSlash && *(lastSlash + 1)) base = lastSlash + 1; if (!base || !*base) { entry.close(); continue; } // .plbのみ const size_t len = strlen(base); if (len < 4 || strcasecmp(base + (len - 4), ".plb") != 0) { entry.close(); continue; } if (playlistCount_ >= kMaxPlaylists) { entry.close(); break; } strncpy(playlistNames_[playlistCount_], base, sizeof(playlistNames_[0]) - 1); playlistNames_[playlistCount_][sizeof(playlistNames_[0]) - 1] = '\0'; // "PLAYLISTS/<name>" snprintf(playlistPaths_[playlistCount_], sizeof(playlistPaths_[0]), "%s/%s", dirBuf, base); ++playlistCount_; entry.close(); } dir.close(); } }; } // namespace app ``` ```c:include/app/AppState.h #pragma once #include <Arduino.h> namespace app { enum class ScreenId : uint8_t { Loading = 0, Home, PlaylistSelect, ArtistSelect, AlbumSelect, YearSelect, ScreenSaver, NowPlaying, UsbMsc, Error, }; enum class NowPlayingMode : uint8_t { Normal = 0, Decorated, Minimal, }; struct ResumeState { bool valid = false; // 将来: playlistPath / playlistIndex / trackId / positionMs }; struct ErrorState { char message[64] = {0}; }; struct HomeState { uint8_t selected = 0; }; struct PlaylistSelectState { uint8_t selected = 0; }; struct ArtistSelectState { uint16_t selected = 0; }; struct AlbumSelectState { uint16_t selected = 0; }; struct YearSelectState { uint16_t selected = 0; }; struct ScreenSaverState { bool active = false; }; struct NowPlayingState { NowPlayingMode mode = NowPlayingMode::Normal; bool playing = false; bool paused = false; bool loop = false; bool shuffle = false; uint16_t queueCount = 0; uint16_t queueIndex = 0; uint8_t volumePercent = 0; uint32_t durationMs = 0; // 表示用(UTF-8) char title[256] = {0}; char artist[256] = {0}; char album[256] = {0}; char source[256] = {0}; // "ALL" or "PLB" }; struct AppState { ScreenId screen = ScreenId::Loading; HomeState home{}; PlaylistSelectState playlist{}; ArtistSelectState artist{}; AlbumSelectState album{}; YearSelectState year{}; ScreenSaverState screensaver{}; NowPlayingState nowPlaying{}; ResumeState resume{}; ErrorState error{}; // 画面遷移用(戻るを簡単にする) ScreenId prev = ScreenId::Home; }; } // namespace app ``` ```c:include/Buttons.h #pragma once #include <Arduino.h> class Buttons { public: struct Config { const uint8_t *pins; size_t count; bool usePullups = true; // true: INPUT_PULLUPで押下=LOW uint16_t debounceMs = 30; // チャタリング除去 Config(const uint8_t *pins_ = nullptr, size_t count_ = 0, bool usePullups_ = true, uint16_t debounceMs_ = 30) : pins(pins_), count(count_), usePullups(usePullups_), debounceMs(debounceMs_) {} }; explicit Buttons(const Config &config) : config_(config) { state_.resize(config_.count); } void begin() { for (size_t i = 0; i < config_.count; ++i) { pinMode(config_.pins[i], config_.usePullups ? INPUT_PULLUP : INPUT); } const uint32_t now = millis(); for (size_t i = 0; i < config_.count; ++i) { const bool pressed = readPressedRaw(i); state_[i].stablePressed = pressed; state_[i].lastStablePressed = pressed; state_[i].changedAtMs = now; state_[i].fell = false; state_[i].rose = false; } } void update() { const uint32_t now = millis(); for (size_t i = 0; i < config_.count; ++i) { state_[i].fell = false; state_[i].rose = false; const bool rawPressed = readPressedRaw(i); if (rawPressed != state_[i].stablePressed) { if (now - state_[i].changedAtMs >= config_.debounceMs) { state_[i].changedAtMs = now; state_[i].lastStablePressed = state_[i].stablePressed; state_[i].stablePressed = rawPressed; if (!state_[i].lastStablePressed && state_[i].stablePressed) { state_[i].fell = true; } else if (state_[i].lastStablePressed && !state_[i].stablePressed) { state_[i].rose = true; } } } else { state_[i].changedAtMs = now; } } } bool pressed(size_t index) const { return (index < state_.size) ? state_[index].stablePressed : false; } bool fell(size_t index) const { return (index < state_.size) ? state_[index].fell : false; } bool rose(size_t index) const { return (index < state_.size) ? state_[index].rose : false; } uint32_t pressedMask() const { uint32_t mask = 0; const size_t n = (state_.size < 32) ? state_.size : 32; for (size_t i = 0; i < n; ++i) { if (state_[i].stablePressed) { mask |= (1u << i); } } return mask; } size_t count() const { return state_.size; } private: struct ButtonState { bool stablePressed = false; bool lastStablePressed = false; bool fell = false; bool rose = false; uint32_t changedAtMs = 0; }; bool readPressedRaw(size_t index) const { const uint8_t pin = config_.pins[index]; const int level = digitalRead(pin); return config_.usePullups ? (level == LOW) : (level == HIGH); } // Arduino coreでvectorが使える前提(SpresenseはOK) // N=6程度なのでオーバーヘッドは軽微。 struct Vec { ButtonState *data = nullptr; size_t size = 0; void resize(size_t n) { delete[] data; data = new ButtonState[n](); size = n; } ButtonState &operator[](size_t i) { return data[i]; } const ButtonState &operator[](size_t i) const { return data[i]; } ~Vec() { delete[] data; } }; const Config config_; Vec state_; }; ``` ```c:include/OledUi.h #pragma once #include <Arduino.h> #include <Wire.h> #include <U8g2lib.h> class OledUi { public: struct Config { uint8_t defaultAddr7; uint8_t resetPin; Config(uint8_t defaultAddr7_ = 0x3C, uint8_t resetPin_ = U8X8_PIN_NONE) : defaultAddr7(defaultAddr7_), resetPin(resetPin_) {} }; OledUi() : OledUi(Config()) {} explicit OledUi(const Config &config) : config_(config), u8g2_(U8G2_R0, /* reset=*/ config.resetPin) {} void begin() { Wire.begin(); const uint8_t addr7 = detectOledAddress7bit(); // U8g2は8-bit表記のI2Cアドレス u8g2_.setI2CAddress(addr7 << 1); u8g2_.begin(); enableJapanese(); begun_ = true; } void enableJapanese() { // UTF-8をprint系で扱えるようにする u8g2_.enableUTF8Print(); // 日本語グリフを含むフォント // unifont系(16px)は128x64では行間が足りず被りやすい。 // 10pxは潰れて見えることがあるため、デフォルトは12px系にする。 u8g2_.setFont(u8g2_font_b12_t_japanese3); } U8G2 &gfx() { return u8g2_; } void clear() { u8g2_.clearBuffer(); } void send() { u8g2_.sendBuffer(); } void drawHeader(const char *utf8) { u8g2_.setCursor(0, 12); u8g2_.print(utf8); } void drawLine(uint8_t lineIndex, const char *utf8) { // 1行=12px想定(フォントにより調整可) const uint8_t y = 12 + (lineIndex * 12); u8g2_.setCursor(0, y); u8g2_.print(utf8); } void drawUtf8(uint8_t x, uint8_t y, const char *utf8) { u8g2_.drawUTF8(x, y, utf8); } uint8_t oledAddr7() const { return oledAddr7_; } bool isReady() const { return begun_; } private: static bool i2cProbe7bit(uint8_t addr7) { Wire.beginTransmission(addr7); return (Wire.endTransmission() == 0); } uint8_t detectOledAddress7bit() { if (i2cProbe7bit(0x3C)) { oledAddr7_ = 0x3C; return oledAddr7_; } if (i2cProbe7bit(0x3D)) { oledAddr7_ = 0x3D; return oledAddr7_; } oledAddr7_ = config_.defaultAddr7; return oledAddr7_; } Config config_; uint8_t oledAddr7_ = 0; bool begun_ = false; // SSD1309 128x64 (I2C) U8G2_SSD1309_128X64_NONAME0_F_HW_I2C u8g2_; }; ``` ```c:include/player/IMusicPlayer.h #pragma once #include <Arduino.h> #include <spdb/ISpdb.h> #include <spdb/IPlaylist.h> #include <spdb/Status.h> namespace player { enum class RepeatMode : uint8_t { Off = 0, One, All, }; // High-level player orchestration (metadata + playlist + audio backend). // Audio decoding/output is implementation-specific (MP3 only, etc.). class IMusicPlayer { public: virtual ~IMusicPlayer() = default; virtual spdb::Status begin(spdb::ISpdb &db) = 0; virtual void end() = 0; // Playlist control virtual spdb::Status setPlaylist(spdb::IPlaylist &pl) = 0; virtual spdb::Status clearPlaylist() = 0; virtual void setRepeatMode(RepeatMode mode) = 0; virtual RepeatMode repeatMode() const = 0; // Playback control virtual spdb::Status playTrack(spdb::TrackId track_id) = 0; virtual spdb::Status play() = 0; // resume virtual spdb::Status pause() = 0; virtual spdb::Status stop() = 0; virtual spdb::Status next() = 0; virtual spdb::Status previous() = 0; // State virtual bool isPlaying() const = 0; virtual bool isPaused() const = 0; virtual spdb::Status currentTrack(spdb::TrackId &out_track_id) const = 0; virtual spdb::Status currentPositionMs(uint32_t &out_ms) const = 0; virtual spdb::Status currentDurationMs(uint32_t &out_ms) const = 0; // UI helpers // Fills title/artist/album into provided buffers (UTF-8, NUL-terminated) virtual spdb::Status getTrackDisplayStrings( spdb::TrackId track_id, char *title, size_t title_size, char *artist, size_t artist_size, char *album, size_t album_size) const = 0; }; } // namespace player ``` ```c:include/player/PlayerPaths.h #pragma once namespace player { // SDカードルート基準(SDHCI.open()の相対パス想定) static constexpr const char *kDbPath = "DB/library.bin"; static constexpr const char *kDecoderDir = "/mnt/sd0/BIN"; // Audioライブラリのデコーダ検索パス static constexpr const char *kPlaylistsDir = "PLAYLISTS/"; static constexpr const char *kPlaylistsMetaPath = "DB/playlists.bin"; } // namespace player ``` ````text:include/README This directory is intended for project header files. A header file is a file containing C declarations and macro definitions to be shared between several project source files. You request the use of a header file in your project source file (C, C++, etc) located in `src` folder by including it, with the C preprocessing directive `#include'. ```src/main.c #include "header.h" int main (void) { ... } ``` Including a header file produces the same results as copying the header file into each source file that needs it. Such copying would be time-consuming and error-prone. With a header file, the related declarations appear in only one place. If they need to be changed, they can be changed in one place, and programs that include the header file will automatically use the new version when next recompiled. The header file eliminates the labor of finding and changing all the copies as well as the risk that a failure to find one copy will result in inconsistencies within a program. In C, the convention is to give header files names that end with `.h'. Read more about using header files in official GCC documentation: * Include Syntax * Include Operation * Once-Only Headers * Computed Includes https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html ```` ```c:include/spdb/IPlaylist.h #pragma once #include <Arduino.h> #include <spdb/SpdbTypes.h> #include <spdb/Status.h> namespace spdb { // Playlist (.plb) abstraction. // The preferred format is PLB v1: TrackId(u16) list. class IPlaylist { public: virtual ~IPlaylist() = default; virtual Status open(const char *path) = 0; virtual void close() = 0; virtual bool isOpen() const = 0; virtual Status reset() = 0; // set cursor to first track virtual Status seek(uint32_t index) = 0; // optional: index-based seek virtual Status count(uint32_t &out_count) const = 0; virtual Status next(TrackId &out_track_id) = 0; // sequential read virtual Status currentIndex(uint32_t &out_index) const = 0; }; } // namespace spdb ``` ```c:include/spdb/ISpdb.h #pragma once #include <Arduino.h> #include <spdb/SpdbTypes.h> #include <spdb/Status.h> namespace spdb { // SPDB v2 reader abstraction. // - Implementation should do random-access reads (no full load) // - Strings are UTF-8 bytes in string pool (not NUL-terminated) class ISpdb { public: virtual ~ISpdb() = default; virtual Status open(const char *path) = 0; virtual void close() = 0; virtual bool isOpen() const = 0; virtual Status readHeader(Header &out) const = 0; virtual uint16_t artistCount() const = 0; virtual uint16_t albumCount() const = 0; virtual uint16_t trackCount() const = 0; virtual Status readArtist(ArtistId artist_id, ArtistRec &out) const = 0; virtual Status readAlbum(AlbumId album_id, AlbumRec &out) const = 0; virtual Status readTrack(TrackId track_id, TrackRec &out) const = 0; // Read UTF-8 bytes from string pool, and NUL-terminate. // Returns BufferTooSmall if buf_size < len+1. virtual Status readString(const StringRef &ref, char *buf, size_t buf_size) const = 0; // Link arrays (u16) virtual Status artistAlbums(ArtistId artist_id, AlbumId *out_ids, uint16_t max, uint16_t &out_count) const = 0; virtual Status albumTracks(AlbumId album_id, TrackId *out_ids, uint16_t max, uint16_t &out_count) const = 0; }; } // namespace spdb ``` ```c:include/spdb/PlbV1Sdhci.h #pragma once #include <Arduino.h> #include <SDHCI.h> #include <spdb/IPlaylist.h> namespace spdb { class PlbV1Sdhci final : public IPlaylist { public: explicit PlbV1Sdhci(SDClass &sd) : sd_(sd) {} Status open(const char *path) override { close(); if (!path || !*path) return Status::err(ErrorCode::FormatError); const char *p = normalizePath_(path); file_ = sd_.open(p); if (!file_) return Status::err(ErrorCode::IoError); Status st = readHeader_(); if (!st) { close(); return st; } open_ = true; index_ = 0; return Status::ok(); } void close() override { if (file_) file_.close(); open_ = false; count_ = 0; index_ = 0; } bool isOpen() const override { return open_; } Status reset() override { return seek(0); } Status seek(uint32_t index) override { if (!open_) return Status::err(ErrorCode::NotOpen); if (index > count_) return Status::err(ErrorCode::OutOfRange, index); const uint32_t off = kHeaderSize + index * 2; if (!file_.seek(off)) return Status::err(ErrorCode::IoError, off); index_ = index; return Status::ok(); } Status count(uint32_t &out_count) const override { if (!open_) return Status::err(ErrorCode::NotOpen); out_count = count_; return Status::ok(); } Status next(TrackId &out_track_id) override { if (!open_) return Status::err(ErrorCode::NotOpen); if (index_ >= count_) return Status::err(ErrorCode::OutOfRange, index_); uint8_t b[2] = {}; const int n = file_.read(b, sizeof(b)); if (n != static_cast<int>(sizeof(b))) return Status::err(ErrorCode::IoError); out_track_id = static_cast<TrackId>(le16_(b)); ++index_; return Status::ok(); } Status currentIndex(uint32_t &out_index) const override { if (!open_) return Status::err(ErrorCode::NotOpen); out_index = index_; return Status::ok(); } private: static constexpr uint32_t kHeaderSize = 12; // magic[4], version(u16), flags(u16), count(u32) static const char *normalizePath_(const char *path) { if (path[0] == '/') return path + 1; return path; } static uint16_t le16_(const uint8_t *p) { return static_cast<uint16_t>(p[0] | (static_cast<uint16_t>(p[1]) << 8)); } static uint32_t le32_(const uint8_t *p) { return static_cast<uint32_t>(p[0] | (static_cast<uint32_t>(p[1]) << 8) | (static_cast<uint32_t>(p[2]) << 16) | (static_cast<uint32_t>(p[3]) << 24)); } Status readHeader_() { uint8_t buf[kHeaderSize] = {}; if (!file_.seek(0)) return Status::err(ErrorCode::IoError); const int n = file_.read(buf, sizeof(buf)); if (n != static_cast<int>(sizeof(buf))) return Status::err(ErrorCode::IoError); if (!(buf[0] == 'P' && buf[1] == 'L' && buf[2] == 'B' && buf[3] == '1')) { return Status::err(ErrorCode::FormatError); } const uint16_t version = le16_(buf + 4); if (version != 1) { return Status::err(ErrorCode::Unsupported, version); } count_ = le32_(buf + 8); return Status::ok(); } SDClass &sd_; File file_; bool open_ = false; uint32_t count_ = 0; uint32_t index_ = 0; }; } // namespace spdb ``` ```c:include/spdb/PlmV1Sdhci.h #pragma once #include <Arduino.h> #include <SDHCI.h> #include <spdb/SpdbTypes.h> #include <spdb/Status.h> namespace spdb { struct PlaylistMetaRec { StringRef name; StringRef plb_path; uint32_t track_count = 0; }; // SDHCI(SDClass/File) ベースの PLM v1 (playlist metadata) リーダ class PlmV1Sdhci final { public: explicit PlmV1Sdhci(SDClass &sd) : sd_(sd) {} Status open(const char *path) { close(); if (!path || !*path) return Status::err(ErrorCode::FormatError); const char *p = normalizePath_(path); file_ = sd_.open(p); if (!file_) return Status::err(ErrorCode::IoError); Status st = readHeader_(); if (!st) { close(); return st; } open_ = true; return Status::ok(); } void close() { if (file_) file_.close(); open_ = false; count_ = 0; off_items_ = 0; off_string_pool_ = 0; string_size_ = 0; } bool isOpen() const { return open_; } Status count(uint32_t &out_count) const { if (!open_) return Status::err(ErrorCode::NotOpen); out_count = count_; return Status::ok(); } Status readMeta(uint32_t index, PlaylistMetaRec &out) const { if (!open_) return Status::err(ErrorCode::NotOpen); if (index >= count_) return Status::err(ErrorCode::OutOfRange, index); const uint32_t base = off_items_ + index * kItemSize; uint8_t buf[kItemSize] = {}; Status st = readAt_(base, buf, sizeof(buf)); if (!st) return st; out = PlaylistMetaRec{}; out.name.off = le32_(buf + 0); out.name.len = le16_(buf + 4); out.plb_path.off = le32_(buf + 6); out.plb_path.len = le16_(buf + 10); out.track_count = le32_(buf + 12); return Status::ok(); } Status readString(const StringRef &ref, char *buf, size_t buf_size) const { if (!open_) return Status::err(ErrorCode::NotOpen); if (!buf || buf_size == 0) return Status::err(ErrorCode::BufferTooSmall); if (buf_size < static_cast<size_t>(ref.len) + 1) return Status::err(ErrorCode::BufferTooSmall, ref.len); const uint32_t base = off_string_pool_ + ref.off; Status st = readAt_(base, reinterpret_cast<uint8_t *>(buf), ref.len); if (!st) return st; buf[ref.len] = '\0'; return Status::ok(); } private: static constexpr uint32_t kHeaderSize = 32; static constexpr uint32_t kItemSize = 20; static const char *normalizePath_(const char *path) { if (path[0] == '/') return path + 1; return path; } static uint16_t le16_(const uint8_t *p) { return static_cast<uint16_t>(p[0] | (static_cast<uint16_t>(p[1]) << 8)); } static uint32_t le32_(const uint8_t *p) { return static_cast<uint32_t>(p[0] | (static_cast<uint32_t>(p[1]) << 8) | (static_cast<uint32_t>(p[2]) << 16) | (static_cast<uint32_t>(p[3]) << 24)); } Status readAt_(uint32_t offset, uint8_t *dst, size_t len) const { const uint32_t end = offset + static_cast<uint32_t>(len); if (file_size_ > 0 && end > file_size_) { return Status::err(ErrorCode::OutOfRange, offset); } if (!file_.seek(offset)) return Status::err(ErrorCode::IoError, offset); const int n = file_.read(dst, len); if (n != static_cast<int>(len)) return Status::err(ErrorCode::IoError, offset); return Status::ok(); } Status readHeader_() { uint8_t buf[kHeaderSize] = {}; if (!file_.seek(0)) return Status::err(ErrorCode::IoError, 0); const int n = file_.read(buf, sizeof(buf)); if (n != static_cast<int>(sizeof(buf))) return Status::err(ErrorCode::IoError, 0); if (!(buf[0] == 'P' && buf[1] == 'L' && buf[2] == 'M' && buf[3] == '1')) { return Status::err(ErrorCode::FormatError); } const uint16_t version = le16_(buf + 4); const uint16_t header_size = le16_(buf + 6); if (version != 1 || header_size != kHeaderSize) { return Status::err(ErrorCode::Unsupported, (static_cast<uint32_t>(version) << 16) | header_size); } count_ = le32_(buf + 12); off_items_ = le32_(buf + 16); off_string_pool_ = le32_(buf + 20); string_size_ = le32_(buf + 24); file_size_ = off_string_pool_ + string_size_; if (off_items_ < kHeaderSize || off_string_pool_ < off_items_) { return Status::err(ErrorCode::FormatError); } return Status::ok(); } SDClass &sd_; mutable File file_; bool open_ = false; uint32_t count_ = 0; uint32_t off_items_ = 0; uint32_t off_string_pool_ = 0; uint32_t string_size_ = 0; uint32_t file_size_ = 0; }; } // namespace spdb ``` ```c:include/spdb/SpdbTypes.h #pragma once #include <Arduino.h> namespace spdb { using TrackId = uint16_t; using AlbumId = uint16_t; using ArtistId = uint16_t; struct StringRef { uint32_t off = 0; uint16_t len = 0; }; // Host-endian decoded record types (fixed-length records in SPDB v2) struct ArtistRec { StringRef name; uint16_t album_link_count = 0; uint32_t album_link_start = 0; }; struct AlbumRec { StringRef name; ArtistId artist_id = 0; uint16_t year = 0; uint16_t track_link_count = 0; uint32_t track_link_start = 0; }; struct TrackRec { StringRef title; AlbumId album_id = 0; ArtistId artist_id = 0; uint16_t track_no = 0; uint16_t disc_no = 0; uint32_t duration_ms = 0; StringRef path; // OutRelPath uint8_t codec = 0; uint8_t flags = 0; uint16_t track_year = 0; // SPDB v2: reserved_u16 }; struct Header { uint16_t version = 0; uint16_t header_size = 0; uint32_t flags = 0; uint32_t build_epoch = 0; uint32_t db_size = 0; uint16_t artist_count = 0; uint16_t album_count = 0; uint16_t track_count = 0; uint32_t off_artists = 0; uint32_t off_albums = 0; uint32_t off_tracks = 0; uint32_t off_artist_album_index = 0; uint32_t off_album_track_index = 0; uint32_t off_string_pool = 0; uint32_t total_artist_album_links = 0; uint32_t total_album_track_links = 0; }; enum class Codec : uint8_t { Unknown = 0, Mp3 = 1, Wav = 2, Flac = 3, M4aMp4 = 4, Ogg = 5, Opus = 6, Aac = 7, }; } // namespace spdb ``` ```c:include/spdb/SpdbV2Sdhci.h #pragma once #include <Arduino.h> #include <SDHCI.h> #include <spdb/ISpdb.h> namespace spdb { // SDHCI(SDClass/File) ベースの SPDB v2 ランダムアクセスリーダ。 // - 仕様書の固定長レコード/リンク配列/String Pool を部分読みで参照 class SpdbV2Sdhci final : public ISpdb { public: explicit SpdbV2Sdhci(SDClass &sd) : sd_(sd) {} Status open(const char *path) override { close(); if (!path || !*path) { return Status::err(ErrorCode::FormatError); } const char *p = normalizePath_(path); file_ = sd_.open(p); if (!file_) { return Status::err(ErrorCode::IoError); } Status st = readHeader_(header_); if (!st) { close(); return st; } open_ = true; return Status::ok(); } void close() override { if (file_) { file_.close(); } open_ = false; header_ = Header{}; } bool isOpen() const override { return open_; } Status readHeader(Header &out) const override { if (!open_) { return Status::err(ErrorCode::NotOpen); } out = header_; return Status::ok(); } uint16_t artistCount() const override { return header_.artist_count; } uint16_t albumCount() const override { return header_.album_count; } uint16_t trackCount() const override { return header_.track_count; } Status readArtist(ArtistId artist_id, ArtistRec &out) const override { if (!open_) return Status::err(ErrorCode::NotOpen); if (artist_id >= header_.artist_count) return Status::err(ErrorCode::OutOfRange, artist_id); const uint32_t base = header_.off_artists + static_cast<uint32_t>(artist_id) * kArtistRecSize; uint8_t buf[kArtistRecSize] = {}; Status st = readAt_(base, buf, sizeof(buf)); if (!st) return st; out = ArtistRec{}; out.name.off = le32_(buf + 0); out.name.len = le16_(buf + 4); out.album_link_count = le16_(buf + 6); out.album_link_start = le32_(buf + 8); return Status::ok(); } Status readAlbum(AlbumId album_id, AlbumRec &out) const override { if (!open_) return Status::err(ErrorCode::NotOpen); if (album_id >= header_.album_count) return Status::err(ErrorCode::OutOfRange, album_id); const uint32_t base = header_.off_albums + static_cast<uint32_t>(album_id) * kAlbumRecSize; uint8_t buf[kAlbumRecSize] = {}; Status st = readAt_(base, buf, sizeof(buf)); if (!st) return st; out = AlbumRec{}; out.name.off = le32_(buf + 0); out.name.len = le16_(buf + 4); out.artist_id = static_cast<ArtistId>(le16_(buf + 6)); out.year = le16_(buf + 8); out.track_link_count = le16_(buf + 10); out.track_link_start = le32_(buf + 12); return Status::ok(); } Status readTrack(TrackId track_id, TrackRec &out) const override { if (!open_) return Status::err(ErrorCode::NotOpen); if (track_id >= header_.track_count) return Status::err(ErrorCode::OutOfRange, track_id); const uint32_t base = header_.off_tracks + static_cast<uint32_t>(track_id) * kTrackRecSize; uint8_t buf[kTrackRecSize] = {}; Status st = readAt_(base, buf, sizeof(buf)); if (!st) return st; out = TrackRec{}; out.title.off = le32_(buf + 0); out.title.len = le16_(buf + 4); out.album_id = static_cast<AlbumId>(le16_(buf + 6)); out.artist_id = static_cast<ArtistId>(le16_(buf + 8)); out.track_no = le16_(buf + 10); out.disc_no = le16_(buf + 12); out.duration_ms = le32_(buf + 14); out.path.off = le32_(buf + 18); out.path.len = le16_(buf + 22); out.codec = buf[24]; out.flags = buf[25]; out.track_year = le16_(buf + 26); return Status::ok(); } Status readString(const StringRef &ref, char *buf, size_t buf_size) const override { if (!open_) return Status::err(ErrorCode::NotOpen); if (!buf || buf_size == 0) return Status::err(ErrorCode::BufferTooSmall); if (buf_size < static_cast<size_t>(ref.len) + 1) return Status::err(ErrorCode::BufferTooSmall, ref.len); const uint32_t base = header_.off_string_pool + ref.off; Status st = readAt_(base, reinterpret_cast<uint8_t *>(buf), ref.len); if (!st) return st; buf[ref.len] = '\0'; return Status::ok(); } Status artistAlbums(ArtistId artist_id, AlbumId *out_ids, uint16_t max, uint16_t &out_count) const override { out_count = 0; if (!open_) return Status::err(ErrorCode::NotOpen); if (artist_id >= header_.artist_count) return Status::err(ErrorCode::OutOfRange, artist_id); ArtistRec ar{}; Status st = readArtist(artist_id, ar); if (!st) return st; const uint16_t cnt = ar.album_link_count; out_count = (cnt < max) ? cnt : max; for (uint16_t i = 0; i < out_count; ++i) { const uint32_t off = header_.off_artist_album_index + static_cast<uint32_t>(ar.album_link_start + i) * 2; uint8_t b[2] = {}; st = readAt_(off, b, sizeof(b)); if (!st) return st; out_ids[i] = static_cast<AlbumId>(le16_(b)); } return Status::ok(); } Status albumTracks(AlbumId album_id, TrackId *out_ids, uint16_t max, uint16_t &out_count) const override { out_count = 0; if (!open_) return Status::err(ErrorCode::NotOpen); if (album_id >= header_.album_count) return Status::err(ErrorCode::OutOfRange, album_id); AlbumRec al{}; Status st = readAlbum(album_id, al); if (!st) return st; const uint16_t cnt = al.track_link_count; out_count = (cnt < max) ? cnt : max; for (uint16_t i = 0; i < out_count; ++i) { const uint32_t off = header_.off_album_track_index + static_cast<uint32_t>(al.track_link_start + i) * 2; uint8_t b[2] = {}; st = readAt_(off, b, sizeof(b)); if (!st) return st; out_ids[i] = static_cast<TrackId>(le16_(b)); } return Status::ok(); } private: static constexpr uint32_t kHeaderSize = 92; static constexpr uint32_t kArtistRecSize = 16; static constexpr uint32_t kAlbumRecSize = 24; static constexpr uint32_t kTrackRecSize = 32; // NOTE: 先頭'/'はSDHCI openで不要なケースがあるため除去 static const char *normalizePath_(const char *path) { if (path[0] == '/') return path + 1; return path; } static uint16_t le16_(const uint8_t *p) { return static_cast<uint16_t>(p[0] | (static_cast<uint16_t>(p[1]) << 8)); } static uint32_t le32_(const uint8_t *p) { return static_cast<uint32_t>(p[0] | (static_cast<uint32_t>(p[1]) << 8) | (static_cast<uint32_t>(p[2]) << 16) | (static_cast<uint32_t>(p[3]) << 24)); } Status readAt_(uint32_t offset, uint8_t *dst, size_t len) const { if (header_.db_size && (offset + len) > header_.db_size) { return Status::err(ErrorCode::OutOfRange, offset); } if (!file_.seek(offset)) { return Status::err(ErrorCode::IoError, offset); } const int n = file_.read(dst, len); if (n != static_cast<int>(len)) { return Status::err(ErrorCode::IoError, offset); } return Status::ok(); } Status readHeader_(Header &out) const { uint8_t buf[kHeaderSize] = {}; if (!file_.seek(0)) { return Status::err(ErrorCode::IoError, 0); } const int n = file_.read(buf, sizeof(buf)); if (n != static_cast<int>(sizeof(buf))) { return Status::err(ErrorCode::IoError, 0); } // magic "SPDB" if (!(buf[0] == 'S' && buf[1] == 'P' && buf[2] == 'D' && buf[3] == 'B')) { return Status::err(ErrorCode::FormatError); } out = Header{}; out.version = le16_(buf + 4); out.header_size = le16_(buf + 6); out.flags = le32_(buf + 8); out.build_epoch = le32_(buf + 12); out.db_size = le32_(buf + 16); out.artist_count = le16_(buf + 20); out.album_count = le16_(buf + 22); out.track_count = le16_(buf + 24); if (out.version != 2 || out.header_size != kHeaderSize) { return Status::err(ErrorCode::Unsupported, (static_cast<uint32_t>(out.version) << 16) | out.header_size); } // dword[0..15] start at offset 28 const uint8_t *dw = buf + 28; out.off_artists = le32_(dw + 0); out.off_albums = le32_(dw + 4); out.off_tracks = le32_(dw + 8); out.off_artist_album_index = le32_(dw + 12); out.off_album_track_index = le32_(dw + 16); out.off_string_pool = le32_(dw + 20); out.total_artist_album_links = le32_(dw + 24); out.total_album_track_links = le32_(dw + 28); // Basic sanity checks if (out.off_artists != kHeaderSize) { // allow but usually should match } if (out.db_size == 0) { return Status::err(ErrorCode::FormatError); } return Status::ok(); } SDClass &sd_; mutable File file_; bool open_ = false; Header header_{}; }; } // namespace spdb ``` ```c:include/spdb/Status.h #pragma once #include <Arduino.h> namespace spdb { enum class ErrorCode : uint8_t { Ok = 0, NotOpen, IoError, FormatError, OutOfRange, BufferTooSmall, Unsupported, }; struct Status { ErrorCode code = ErrorCode::Ok; uint32_t detail = 0; Status() : code(ErrorCode::Ok), detail(0) {} Status(ErrorCode c, uint32_t d) : code(c), detail(d) {} static Status ok() { return Status(ErrorCode::Ok, 0); } static Status err(ErrorCode c, uint32_t d = 0) { return Status(c, d); } explicit operator bool() const { return code == ErrorCode::Ok; } }; } // namespace spdb ``` ```cpp:src/AppControllerIcons.cpp #include <app/AppController.h> namespace app { // Music note (8th note) const uint8_t AppController::kIconMusic[8] = {0x00, 0x70, 0x30, 0x10, 0x18, 0x1E, 0x0F, 0x00}; const uint8_t AppController::kIconPerson[8] = {0x18, 0x24, 0x24, 0x18, 0x3C, 0x24, 0x24, 0x00}; const uint8_t AppController::kIconPlay[8] = {0x10, 0x18, 0x1C, 0x1E, 0x1C, 0x18, 0x10, 0x00}; const uint8_t AppController::kIconPause[8] = {0x36, 0x36, 0x36, 0x36, 0x36, 0x36, 0x36, 0x00}; const uint8_t AppController::kIconStop[8] = {0x3C, 0x3C, 0x3C, 0x3C, 0x3C, 0x3C, 0x3C, 0x00}; const uint8_t AppController::kIconLoop[8] = {0x1C, 0x22, 0x40, 0x5E, 0x02, 0x44, 0x38, 0x00}; const uint8_t AppController::kIconShuffle[8] = {0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x00, 0x00}; // USB icon (simplified trident) const uint8_t AppController::kIconUsb[8] = {0x08, 0x1C, 0x2A, 0x08, 0x49, 0x22, 0x1C, 0x00}; // Battery icons (positive terminal at TOP) // Outline uses a 6x7 body (centered) + terminal on top. const uint8_t AppController::kIconBatteryEmpty[8] = {0x18, 0x7E, 0x42, 0x42, 0x42, 0x42, 0x7E, 0x00}; const uint8_t AppController::kIconBatteryLow[8] = {0x18, 0x7E, 0x42, 0x42, 0x42, 0x5A, 0x7E, 0x00}; const uint8_t AppController::kIconBatteryHalf[8] = {0x18, 0x7E, 0x5A, 0x5A, 0x42, 0x42, 0x7E, 0x00}; const uint8_t AppController::kIconBatteryFull[8] = {0x18, 0x7E, 0x7E, 0x7E, 0x7E, 0x7E, 0x7E, 0x00}; // Speaker with waves const uint8_t AppController::kIconSpeaker[8] = {0x00, 0x00, 0x4F, 0xDF, 0xDF, 0x4F, 0x00, 0x00}; // Playlist (list) icon const uint8_t AppController::kIconPlaylist[8] = {0x00, 0xFD, 0x00, 0xFD, 0x00, 0xFD, 0x00, 0x00}; // Artist (head + shoulders) const uint8_t AppController::kIconArtist[8] = {0x18, 0x24, 0x24, 0x18, 0x3C, 0x24, 0x24, 0x00}; // Album (disc in frame) const uint8_t AppController::kIconAlbum[8] = {0x7E, 0x42, 0x5A, 0x66, 0x66, 0x5A, 0x42, 0x7E}; // Year (calendar-ish) const uint8_t AppController::kIconYear[8] = {0x3C, 0x24, 0x7E, 0x42, 0x5A, 0x5A, 0x42, 0x7E}; } // namespace app ``` ```cpp:src/main.cpp #include <Arduino.h> #include <Wire.h> #include <Buttons.h> #include <OledUi.h> #include <LowPower.h> #include <app/AppConfig.h> #include <app/AppController.h> static app::AppConfig g_cfg; static Buttons g_buttons(Buttons::Config(g_cfg.buttonPins, 6, g_cfg.buttonUsePullups, g_cfg.buttonDebounceMs)); static OledUi g_oled; static app::AppController g_app(g_cfg, g_buttons, g_oled); namespace { class AccelTapDetector { public: enum class ShakeMode : uint8_t { Normal, ScreenSaver }; void begin() { Wire.begin(); // Wake MPU-6050 writeReg_(kRegPwrMgmt1, 0x00); const uint8_t who = readReg_(kRegWhoAmI); Serial.print("[MPU] WHO_AM_I=0x"); Serial.println(who, HEX); } void update(uint32_t nowMs) { if (nowMs - lastSampleMs_ < kSampleMs) return; lastSampleMs_ = nowMs; if (cooldownUntilMs_ != 0 && nowMs < cooldownUntilMs_) return; int16_t axRaw = 0, ayRaw = 0, azRaw = 0; if (!readAccel_(axRaw, ayRaw, azRaw)) return; lastAx_ = axRaw; lastAy_ = ayRaw; lastAz_ = azRaw; if (debugCalib_ && (nowMs - lastCalibPrintMs_) >= kCalibPrintMs) { lastCalibPrintMs_ = nowMs; const char *dir = tiltDir_(axRaw, ayRaw); Serial.print("[CAL] ax="); Serial.print(axRaw); Serial.print(" ay="); Serial.print(ayRaw); Serial.print(" az="); Serial.print(azRaw); Serial.print(" dir="); Serial.println(dir); } const int32_t dx = static_cast<int32_t>(axRaw) - prevAx_; prevAx_ = axRaw; prevAy_ = ayRaw; prevAz_ = azRaw; const int32_t dxAxis = dx; const int32_t dxAbs = (dxAxis >= 0) ? dxAxis : -dxAxis; const bool shakeDetected = (dxAbs >= kShakeThreshold); if (shakeDetected) { const int8_t dir = (dxAxis >= 0) ? 1 : -1; if (nowMs - lastShakeMs_ < kShakeDebounceMs) return; if (lastShakeMs_ == 0 || (nowMs - lastShakeMs_) > kShakeWindowMs) { shakeCount_ = 0; lastShakeDir_ = 0; pendingDouble_ = false; } if (dir != lastShakeDir_) { ++shakeCount_; lastShakeDir_ = dir; lastShakeMs_ = nowMs; if (kDebugTapLog) { Serial.print("[MPU] shakeX count="); Serial.print(shakeCount_); Serial.print(" strength="); Serial.print(dxAbs); Serial.print(" dx="); Serial.println(dxAxis); } if (mode_ == ShakeMode::ScreenSaver) { if (shakeCount_ >= 8) { octShake_ = true; cooldownUntilMs_ = nowMs + kCooldownMs; resetShake_(); } } else if (shakeCount_ >= 6) { tripleShake_ = true; cooldownUntilMs_ = nowMs + kCooldownMs; resetShake_(); } else if (shakeCount_ >= 4) { // Defer 4-shake action to allow 6-shake to occur pendingDouble_ = true; pendingSinceMs_ = nowMs; } } } if (mode_ == ShakeMode::Normal && pendingDouble_) { if ((nowMs - pendingSinceMs_) >= kDoubleConfirmMs) { doubleShake_ = true; cooldownUntilMs_ = nowMs + kCooldownMs; resetShake_(); pendingDouble_ = false; } } } bool consumeDoubleShake() { if (!doubleShake_) return false; doubleShake_ = false; return true; } bool consumeTripleShake() { if (!tripleShake_) return false; tripleShake_ = false; return true; } bool consumeOctShake() { if (!octShake_) return false; octShake_ = false; return true; } void setMode(ShakeMode mode) { mode_ = mode; } void setDebugCalib(bool enable) { debugCalib_ = enable; } void getLastAccel(int16_t &ax, int16_t &ay, int16_t &az) const { ax = lastAx_; ay = lastAy_; az = lastAz_; } private: static constexpr uint8_t kAddr = 0x68; static constexpr uint8_t kRegWhoAmI = 0x75; static constexpr uint8_t kRegPwrMgmt1 = 0x6B; static constexpr uint8_t kRegAccelXoutH = 0x3B; static constexpr uint32_t kSampleMs = 10; // 100 Hz static constexpr uint32_t kShakeDebounceMs = 60; // ignore bounce static constexpr uint32_t kShakeWindowMs = 600; // multi-shake window static constexpr uint32_t kDoubleConfirmMs = 240; // wait for possible 6-shake static constexpr uint32_t kCooldownMs = 1000; // after 2+ shakes static constexpr uint32_t kCalibPrintMs = 100; // debug print interval // Threshold for X-shake detection (raw delta). static constexpr int32_t kShakeThreshold = 9000; static constexpr bool kDebugTapLog = false; uint32_t lastSampleMs_ = 0; uint32_t lastShakeMs_ = 0; uint32_t cooldownUntilMs_ = 0; uint8_t shakeCount_ = 0; int8_t lastShakeDir_ = 0; bool doubleShake_ = false; bool tripleShake_ = false; bool octShake_ = false; ShakeMode mode_ = ShakeMode::Normal; bool debugCalib_ = false; uint32_t lastCalibPrintMs_ = 0; bool pendingDouble_ = false; uint32_t pendingSinceMs_ = 0; int16_t lastAx_ = 0; int16_t lastAy_ = 0; int16_t lastAz_ = 0; int16_t prevAx_ = 0; int16_t prevAy_ = 0; int16_t prevAz_ = 0; void resetShake_() { shakeCount_ = 0; lastShakeDir_ = 0; lastShakeMs_ = 0; pendingDouble_ = false; pendingSinceMs_ = 0; } void writeReg_(uint8_t reg, uint8_t val) { Wire.beginTransmission(kAddr); Wire.write(reg); Wire.write(val); Wire.endTransmission(); } uint8_t readReg_(uint8_t reg) { Wire.beginTransmission(kAddr); Wire.write(reg); Wire.endTransmission(false); Wire.requestFrom(static_cast<int>(kAddr), 1); if (Wire.available() < 1) return 0xFF; return Wire.read(); } static const char *tiltDir_(int16_t ax, int16_t ay) { const int16_t axAbs = (ax >= 0) ? ax : -ax; const int16_t ayAbs = (ay >= 0) ? ay : -ay; if (axAbs > ayAbs) { return (ax >= 0) ? "RIGHT" : "LEFT"; } return (ay >= 0) ? "DOWN" : "UP"; } bool readAccel_(int16_t &axOut, int16_t &ayOut, int16_t &azOut) { Wire.beginTransmission(kAddr); Wire.write(kRegAccelXoutH); if (Wire.endTransmission(false) != 0) return false; Wire.requestFrom(static_cast<int>(kAddr), 6); if (Wire.available() < 6) return false; axOut = static_cast<int16_t>((Wire.read() << 8) | Wire.read()); ayOut = static_cast<int16_t>((Wire.read() << 8) | Wire.read()); azOut = static_cast<int16_t>((Wire.read() << 8) | Wire.read()); return true; } }; AccelTapDetector g_accel; } // namespace void setup() { Serial.begin(115200); delay(200); LowPower.begin(); g_oled.begin(); g_buttons.begin(); g_accel.begin(); g_app.begin(); } void loop() { g_buttons.update(); g_accel.setMode(g_app.isScreenSaver() ? AccelTapDetector::ShakeMode::ScreenSaver : AccelTapDetector::ShakeMode::Normal); if (Serial.available() > 0) { const int c = Serial.read(); if (c == 'c' || c == 'C') { static bool calib = false; calib = !calib; g_accel.setDebugCalib(calib); Serial.print("[CAL] debug "); Serial.println(calib ? "ON" : "OFF"); } } g_accel.update(millis()); int16_t ax = 0, ay = 0, az = 0; g_accel.getLastAccel(ax, ay, az); g_app.setAccel(ax, ay, az); if (g_accel.consumeDoubleShake()) { g_app.onDoubleShake(); } if (g_accel.consumeTripleShake()) { g_app.onTripleShake(); } if (g_accel.consumeOctShake()) { g_app.onOctShake(); } g_app.update(millis()); g_app.render(); delay(16); } ``` ### SDカードの要求するディレクトリ構造 SDカード内にはPythonスクリプトで生成されたファイルを以下のように配置する必要があります。また、デコーダファイルも同様に配置する必要があります。 ```markdown:SDカード内の想定される構造 (SD root) ├─ BIN/ │ ├─ MP3DEC │ └─ WAVDEC ├─ MUSIC/ │ └─ ... (変換済みMP3) ├─ DB/ │ ├─ library.bin (ファイル情報DBのバイナリ) │ └─ playlists.bin (すべてのプレイリスト情報を記録したバイナリ) └─ PLAYLISTS/ └─ *.plb (プレイリスト単体のバイナリファイル) ``` ### DB仕様書 ```markdown:データベース仕様書 # Spresense 音楽プレーヤー向け SDカード仕様(SPDB v2 + PLBプレイリスト) 本仕様は、PC側ビルダー(SD MP3 Builder GUI)で生成した **音源ファイル** と **メタデータDB(SPDB v2)**、および **プレイリスト(.plb)** を、Spresense(MCU)側で低負荷に参照・再生するための **SDカード格納方式とバイナリフォーマット** を定義する。 * 対象規模: 最大 20,000 曲程度 * 表示: モノクロOLED(アルバム画像等は非対象) * メタデータ: 日本語を含むUTF-8 --- ## 1. 用語 * **TrackID**: `library.bin` 内の Track レコード番号(0-based)。最大 65,535 まで。 * **ArtistID / AlbumID**: `library.bin` 内の Artist/Album レコード番号(0-based)。最大 65,535 まで。 * **OutRelPath**: SDカードルートからの相対パス(例: `MUSIC/Artist/Album/001.mp3`)。区切りは `/`。 --- ## 2. SDカード ディレクトリ構成(推奨) SDカードの **ルート直下** に以下を配置する。 /MUSIC/ ...(再生対象のMP3ファイル) /DB/ library.bin (SPDB v2 メタデータDB) playlists.bin (PLM v1: プレイリスト名メタデータ) glyphset.txt (任意: フォント最小化用、UTF-8文字集合) playlists.json (任意: PC側GUIの状態保存。MCU側は不要) /PLAYLISTS/ <name>.plb (推奨: TrackIDベースのプレイリスト) <name>.m3u8 (任意: デバッグ/人間向け) ### 2.1 音源ファイル(/MUSIC) * 出力は **MP3** を基本とする(WAV等を残す運用も可能だが、MCU側プレーヤー実装が対応していること)。 * MCU負荷軽減のため、PC側で **ID3等のメタデータを削除** しておく(MCU側は `library.bin` を参照)。 * ファイルパスは `OutRelPath` と一致すること。 --- ## 3. メタデータDB: SPDB v2(/DB/library.bin) `library.bin` は以下の順で構成される。 1. Header(固定長) 2. ArtistRec 配列(固定長) 3. AlbumRec 配列(固定長) 4. TrackRec 配列(固定長) 5. Artist→Album リンク配列(u16配列) 6. Album→Track リンク配列(u16配列) 7. String Pool(UTF-8連結バイト列) 8. CRC32(任意、flagsで有効化) ### 3.1 エンディアン * **Little Endian** ### 3.2 ヘッダ(Header) * **サイズ:** 92 bytes * **フィールド(順序):** | 項目 | 型 | 説明 | | ------------ | ------- | ------------------------------------------- | | magic | char[4] | 常に `"SPDB"` | | version | u16 | 常に `2` | | header_size | u16 | 常に `92` | | flags | u32 | bit0=CRC32付与(推奨) | | build_epoch | u32 | 生成時刻(UNIX epoch) | | db_size | u32 | ファイル全体サイズ(CRC32含む場合は末尾+4) | | artist_count | u16 | ArtistRec数 | | album_count | u16 | AlbumRec数 | | track_count | u16 | TrackRec数 | | reserved0 | u16 | 予約(0) | | dword[0] | u32 | off_artists(ArtistRec先頭オフセット) | | dword[1] | u32 | off_albums(AlbumRec先頭オフセット) | | dword[2] | u32 | off_tracks(TrackRec先頭オフセット) | | dword[3] | u32 | off_artist_album_index(Artist→Albumリンク先頭) | | dword[4] | u32 | off_album_track_index(Album→Trackリンク先頭) | | dword[5] | u32 | off_string_pool(String Pool先頭) | | dword[6] | u32 | total_artist_album_links(Artist→Albumリンク総数) | | dword[7] | u32 | total_album_track_links(Album→Trackリンク総数) | | dword[8..15] | u32 | 予約(0) | #### 3.2.1 CRC32 * `flags & 0x1 != 0` の場合、ファイル末尾に `u32 crc32` が付与される。 * CRC32は **「先頭から crc32フィールド直前まで」** のバイト列に対して計算する(一般的な運用)。 ### 3.3 レコード定義(固定長) #### 3.3.1 ArtistRec(16 bytes) | 項目 | 型 | 説明 | | ---------------- | --- | --------------------------- | | name_off | u32 | String Pool内オフセット | | name_len | u16 | バイト長(UTF-8) | | album_link_count | u16 | このアーティストに属するアルバム数 | | album_link_start | u32 | Artist→Albumリンク配列内の開始インデックス | | reserved | u32 | 予約(0) | #### 3.3.2 AlbumRec(24 bytes) | 項目 | 型 | 説明 | | ---------------- | --- | -------------------------- | | name_off | u32 | String Pool内オフセット | | name_len | u16 | バイト長(UTF-8) | | artist_id | u16 | ArtistID | | year | u16 | アルバム年(YYYY、未知は0) | | track_link_count | u16 | このアルバムに属する曲数 | | track_link_start | u32 | Album→Trackリンク配列内の開始インデックス | | reserved0 | u32 | 予約(0) | | reserved1 | u32 | 予約(0) | #### 3.3.3 TrackRec(32 bytes) | 項目 | 型 | 説明 | | ------------- | --- | ---------------------------------- | | title_off | u32 | String Pool内オフセット | | title_len | u16 | バイト長(UTF-8) | | album_id | u16 | AlbumID | | artist_id | u16 | ArtistID | | track_no | u16 | トラック番号(未知は0) | | disc_no | u16 | ディスク番号(未知は0) | | duration_ms | u32 | ミリ秒 | | path_off | u32 | String Pool内オフセット(OutRelPath) | | path_len | u16 | バイト長(UTF-8) | | codec | u8 | コーデックコード(後述) | | flags | u8 | 予約/フラグ(後述) | | reserved_u16 | u16 | **SPDB v2: track_year(YYYY、未知は0)** | | reserved2_u32 | u32 | 予約(0) | ### 3.4 リンク配列 #### 3.4.1 Artist→Albumリンク配列 * 型: `u16[]`(AlbumIDの配列) * 長さ: `total_artist_album_links` * 各ArtistRecは `album_link_start` と `album_link_count` でスライスを参照する。 #### 3.4.2 Album→Trackリンク配列 * 型: `u16[]`(TrackIDの配列) * 長さ: `total_album_track_links` * 各AlbumRecは `track_link_start` と `track_link_count` でスライスを参照する。 ### 3.5 String Pool * UTF-8バイト列を **連結しただけ** の領域。 * 文字列はNUL終端されない。 * 参照は `(off, len)` で行う。 ### 3.6 codec コード(TrackRec.codec) PC側ビルダーが付与する代表値。 | 値 | 意味 | | -: | ------------------ | | 0 | 不明 | | 1 | MP3 | | 2 | WAV(PCM系含む) | | 3 | FLAC | | 4 | M4A/MP4(AAC/ALAC等) | | 5 | OGG | | 6 | OPUS | | 7 | AAC(拡張子aac等) | ※ MCU側プレーヤーがMP3のみ対応の場合でも、DBの表示やフィルタに利用できる。 --- ## 4. プレイリスト方式(PLM v1 + PLB v1) Spresense側で日本語ファイル名が扱いにくい場合に備え、 **「表示名(UTF-8)とPLB参照」を分離**する。 プレイリスト名は **PLMメタデータ**から取得し、実体のPLBはASCII名で参照する。 ### 4.1 プレイリストメタデータ(PLM v1) * 配置: `/DB/playlists.bin` * エンディアン: Little Endian * 目的: 日本語の表示名をUTF-8で保持し、PLBファイル名に依存しない #### 4.1.1 ヘッダ(32 bytes) | 項目 | 型 | 説明 | | ------------- | ------- | ---- | | magic | char[4] | `"PLM1"` | | version | u16 | `1` | | header_size | u16 | `32` | | flags | u32 | 予約(0) | | count | u32 | プレイリスト数 | | off_items | u32 | Item配列の先頭オフセット | | off_string_pool | u32 | String Pool先頭 | | string_size | u32 | String Poolサイズ | | reserved | u32 | 予約(0) | #### 4.1.2 Item(固定長 20 bytes) | 項目 | 型 | 説明 | | --------- | --- | ---- | | name_off | u32 | String Pool内オフセット(表示名) | | name_len | u16 | バイト長(UTF-8) | | plb_off | u32 | String Pool内オフセット(PLBファイル名/相対パス) | | plb_len | u16 | バイト長 | | track_count | u32 | 曲数(PLBと同値) | | reserved | u32 | 予約(0) | #### 4.1.3 String Pool * UTF-8バイト列を連結 * NUL終端なし((off,len)参照) #### 4.1.4 PLB参照ルール * `plb` は **ASCIIファイル名** を推奨(例: `pl_1a2b3c4d5e6f.plb`) * `plb` に `/` が含まれない場合、MCU側は `PLAYLISTS/` を自動で前置して参照する ### 4.2 プレイリスト本体(PLB v1) 従来の「プレイリスト内にパスを持ち、再生時にパス→検索」方式は、検索コストとI/Oが増える。 本仕様では **プレイリストは TrackID の列**として扱い、MCU側は TrackID から直接 TrackRec を参照する。 #### 4.2.1 ファイル配置 * `/PLAYLISTS/<plb_id>.plb` #### 4.2.2 フォーマット(PLB v1) * エンディアン: Little Endian * 目的: 低負荷・高速パース(逐次読み出し可) | 項目 | 型 | 説明 | | --------- | ---------- | -------------- | | magic | char[4] | `"PLB1"` | | version | u16 | `1` | | flags | u16 | 予約(0) | | count | u32 | TrackID数 | | track_ids | u16[count] | TrackIDの配列 | | crc32(任意) | u32 | 将来拡張(現行は未使用推奨) | * TrackIDは `library.bin` の TrackRec のインデックス(0-based)。 * `count` が大きい場合でも、MCU側は `u16` を逐次読み出すだけでよい。 ### 4.3 参照整合性 * TrackIDは、同一SDカード上の `library.bin` と一致していること。 * ビルドし直した場合に TrackID の並びが変わる運用では、PLM/PLBを同時に再生成する。 --- ## 5. 参考: M3U8(任意) `/PLAYLISTS/<name>.m3u8` は人間向け・デバッグ向け。 * UTF-8 * 先頭行: `#EXTM3U` * 以降、1行1曲で `OutRelPath` を記載(例: `MUSIC/.../xxx.mp3`) MCU側は原則 `.plb` を使用する。 --- ## 6. Spresense側ライブラリ(読み込みAPI)要求仕様(更新版) ### 6.1 目標 * `library.bin` を **部分読み(ランダムアクセス)** で参照 * 文字列は必要時のみデコード(UTF-8) * プレイリスト一覧は `playlists.bin`(PLM)を参照し、曲取得は `.plb` の TrackIDで行う ### 6.2 推奨API(C/C++) #### 6.2.1 DB * `bool spdb_open(const char* path, SpdbHandle* out);` * `void spdb_close(SpdbHandle* h);` * `uint16_t spdb_artist_count(const SpdbHandle* h);` * `uint16_t spdb_album_count(const SpdbHandle* h);` * `uint16_t spdb_track_count(const SpdbHandle* h);` * `bool spdb_read_artist(const SpdbHandle* h, uint16_t artist_id, ArtistRec* out);` * `bool spdb_read_album(const SpdbHandle* h, uint16_t album_id, AlbumRec* out);` * `bool spdb_read_track(const SpdbHandle* h, uint16_t track_id, TrackRec* out);` * `bool spdb_read_string(const SpdbHandle* h, uint32_t off, uint16_t len, char* buf, size_t buf_size);` * bufはNUL終端する * `bool spdb_artist_albums(const SpdbHandle* h, uint16_t artist_id, uint16_t* out_ids, uint16_t max, uint16_t* out_count);` * `bool spdb_album_tracks(const SpdbHandle* h, uint16_t album_id, uint16_t* out_ids, uint16_t max, uint16_t* out_count);` #### 6.2.2 Playlist Metadata(PLM) * `bool plm_open(const char* path, PlmHandle* out);` * `void plm_close(PlmHandle* h);` * `bool plm_read_header(PlmHandle* h, PlmHeader* out);` // magic, version, count * `bool plm_read_item(PlmHandle* h, uint32_t index, PlmItem* out);` * `bool plm_read_string(const PlmHandle* h, uint32_t off, uint16_t len, char* buf, size_t buf_size);` #### 6.2.3 Playlist(PLB) * `bool plb_open(const char* path, PlbHandle* out);` * `void plb_close(PlbHandle* h);` * `bool plb_read_header(PlbHandle* h, PlbHeader* out);` // magic, version, count * `bool plb_read_next(PlbHandle* h, uint16_t* out_track_id);` // 逐次 * `bool plb_seek(PlbHandle* h, uint32_t index);` // 任意(未実装でも可) ### 6.3 MCU側の再生フロー(推奨) 1. 起動時: `spdb_open("/DB/library.bin")` 2. 画面遷移 * Artist一覧: ArtistRecを順に読み、必要なnameのみString Poolから取得 * Album一覧: Artist→Albumリンクを読み、AlbumRecのname/yearを取得 * Track一覧: Album→Trackリンクを読み、TrackRecのtitle/track_no/disc_no/track_year を取得 3. 再生 * TrackRecから `OutRelPath` を読み出し、`/`区切りパスとしてSDから音源ファイルをopen 4. プレイリスト再生 * `playlists.bin` から表示名とPLB参照を取得 * `.plb` から TrackID を逐次読み * TrackID→TrackRec→OutRelPath→open ### 6.4 エラー処理 * magic/version/header_size が不一致なら即エラー * オフセット/サイズが `db_size` を超える場合は破損扱い * TrackIDが `track_count` 以上ならスキップ(プレイリスト破損) * UTF-8デコード失敗時は代替文字(`?` 等)で表示可能 --- ```