Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .claude/settings.local.json

This file was deleted.

30 changes: 18 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ gradlew.bat connectedAndroidTest # 需要设备

- **导航**:自定义 `MusicUiState` 密封类 + `AnimatedContent`,未使用 Navigation 组件(虽依赖 navigation-compose)

- **服务**:`PlaybackService` (MediaBrowserService) 后台播放,`MediaControlManager` 管理媒体会话
- **服务**:`PlaybackService` (MediaBrowserService) 后台播放,`MediaControlManager` 管理媒体会话;`SystemMediaProgressSyncController` 在服务端定时同步播放进度到系统媒体状态/通知栏

- **ViewModel**:`MainViewModel` 作为协调者,由 `MainViewModelFactory` 创建并通过属性赋值持有四个子 ViewModel(`LibraryViewModel`、`SelectionViewModel`、`PlaylistViewModel`、`LoopDetectionViewModel`);其中**自动循环点探测**与试听调度逻辑已被彻底解耦,由全新的子管家 `LoopDetectionViewModel` 进行状态管理,并将耗时调用完全剥离出主线程以防止音频锁与 UI 锁争用。
- **ViewModel/子管家**:`MainViewModel` 作为协调者,由 `MainViewModelFactory` 创建并通过属性赋值持有四个子管家(`LibraryViewModel`、`SelectionViewModel`、`PlaylistViewModel`、`LoopDetectionViewModel`)。注意:`LibraryViewModel` 与 `PlaylistViewModel` 是普通 class,共享 `MainViewModel.viewModelScope`;`SelectionViewModel` 与 `LoopDetectionViewModel` 继承 `ViewModel`,但也是由工厂直接创建后挂到 `MainViewModel` 上。自动循环点探测与试听调度由 `LoopDetectionViewModel` 管理,耗时调用需剥离出主线程以防止音频锁与 UI 锁争用。

- **Native 层**:`app/src/main/cpp/` — 包含两个核心引擎:
1. **播放引擎**:Oboe 1.9.3 + NDK 解码器(minimp3)
2. **探测引擎**:`loopfinder` (基于 FFT/Chroma 分析)
- 统一通过 `NativeAudio.kt` 进行 JNI 桥接
- 注意:`NativeAudio` 的 `init` 块中需同时加载 `seamlessloopmobile` 和 `loopfinder` 两个原生库

- **UI**:Jetpack Compose + Material3,状态通过 ViewModel 的 LiveData + MediaControlManager 的 StateFlow/SharedFlow 驱动
- **UI**:Jetpack Compose + Material3,状态通过 `MainViewModel` 的 LiveData、子管家的 StateFlow/LiveData、`MediaControlManager` 的 StateFlow/SharedFlow 驱动

- **对话框**:统一 `MusicDialog` 密封类 + `CentralizedDialogHost` 集中管理

Expand Down Expand Up @@ -65,15 +65,17 @@ gradlew.bat connectedAndroidTest # 需要设备

- `MusicRepository` — Facade,聚合 3 个子 Repository + PlayQueueDao
- `SongRepository` — 歌曲 CRUD
- `PlaylistRepository` — 播放列表基础 CRUDA/B 检测、PC song 匹配等逻辑在 MusicScannerRepository 中)
- `PlaylistRepository` — 播放列表基础 CRUD 与歌单歌曲关联;不负责 A/B 检测或 PC 数据库匹配
- `LoopDetectionRepository` — **新版逻辑核心**!负责音频临时文件安全拷贝、跨线程 JNI 调用与 JSON 缓存管理。
- `MusicScannerRepository` — 扫描逻辑,含 `getInitialScannedSongs()`(全量扫描+批量更新+Artist/Album 预创建+A/B 标记+双指纹匹配)、`findAbPair()` / `findAbPairRobust()`(DB + MediaStore)
- `MusicScannerRepository` — 扫描逻辑,含 `getInitialScannedSongs()`(全量扫描+批量更新+Artist/Album 预创建+A/B 标记+多级匹配)、`findAbPair()` / `findAbPairRobust()`(DB + MediaStore)
- `SettingsManager` — 单例 `getInstance(context)`,Gson 序列化,持久化 lastSongPath/lastPosition/playMode/isAbMode 等

**扫描流程**:
`AudioScanner.scan()` (MediaStore) → `MusicScannerRepository.getInitialScannedSongs()` → 双指纹匹配 → Artist/Album 预创建 → 批量写入 → A/B 标记(文件后缀 _B/_b/_loop/_Loop 设 `isAbPartB=true`,被大多数查询过滤)
`AudioScanner.scan()` (MediaStore) → `MusicScannerRepository.getInitialScannedSongs()` → 多级匹配(优先 fileName+duration,兼顾 mediaId/filePath/容差匹配)→ Artist/Album 预创建 → 批量写入 → A/B 标记(文件后缀 _B/_b/_loop/_Loop 设 `isAbPartB=true`,被大多数查询过滤)

**PC 数据库导入**:`PcDatabaseImporter`(顶层 object),支持 3NF 和 flat 两种 schema,三级流程(预加载 → 容差匹配 → 事务批量写入)。注意:测试时可以覆盖 `ioDispatcher`/`mainDispatcher` 属性为 `Dispatchers.Unconfined` 来避免死锁。
**PC 数据库同步**:
- `PcDatabaseImporter`(顶层 object)支持 3NF 和 flat 两种 schema,负责 PC song 匹配、循环点/候选循环点 JSON/评分/歌单导入;三级流程(预加载 → 容差匹配 → 事务批量写入)。导入时遵循“有实质循环点才覆盖、PC 评分为 0 不清空手机评分”的保护策略。测试时可以覆盖 `ioDispatcher`/`mainDispatcher` 属性为 `Dispatchers.Unconfined` 来避免死锁。
- `PcDatabaseExporter`(顶层 object)负责将手机端 Room 数据转换为 PC 端可识别的 3NF SQLite 数据库(`Tracks`/`LoopPoints.TrackId`/`UserRatings.TrackId`/`Playlists`/`PlaylistItems` 等),不是原样复制手机 Room 文件。导出时会将手机端 `loopStart`/`loopEnd`/`score`/`noteDiff` 候选点 JSON 转换为 PC 端 `LoopStart`/`LoopEnd`/`Score`/`NoteDifference` 键名。

## 开发注意事项

Expand All @@ -92,6 +94,7 @@ gradlew.bat connectedAndroidTest # 需要设备
- **A/B 过滤**:含 `isAbPartB=true` 的歌曲默认在 UI 列表查询中被排除(由 `SongDao` 查询逻辑保证)
- **GEMINI.md**:为 `AGENTS.md` 的过时副本,应忽略之。
- **PcDatabaseImporter 测试**:需要 `app/src/test/resources/pc_db_samples/pc_3nf_sample.db` 存在(目前仅 `pc_3nf_sample.db` 存在,`pc_flat_sample.db` 不存在),测试中会覆盖 `ioDispatcher` 和 `mainDispatcher`。
- **docs/ 目录**:主要是阶段记录/研究日志,可能过时;改架构说明时优先更新本文件,除非用户明确要求,不要批量改写 `docs/`。
- **快速部署**:项目根目录下的 `run.bat` 提供 root 设备的 adb push + pm install + am start 一键部署流程。
- **⚠️ 物理路径与 JNI 避坑硬约束**:探测引擎的 C++ `fopen` 无法直接读取 `content://` 或 MediaStore URI!自动探测循环点时,必须先使用 `LoopDetectionRepository` 将音频拷贝至私有 cache 目录,再将物理路径传入 `NativeAudio.analyzeLoopPoints`;分析完毕后必须在 `finally` 块中立即彻底删除临时文件,防止磁盘残留。

Expand All @@ -101,11 +104,12 @@ gradlew.bat connectedAndroidTest # 需要设备
|------|------|
| `audio/` | PlaybackService, PlaybackManager (IMultiPlayer), MediaControlManager, QueueManager, AudioFocusManager, Notify, HeadsetPlugReceiver, SystemMediaProgressSyncController 等 |
| `data/` | MusicRepository + SongRepository + PlaylistRepository + MusicScannerRepository + SettingsManager + LoopDetectionRepository |
| `db/` | AppDatabase + DateConverter + PcDatabaseImporter(实体/DAO 已移到 model/) |
| `model/` | 9 个 Room 实体 + 3 个 DAO + Song(Lookup POJO) + SongMetadataUpdate + LibraryItem + Folder 等 15 文件 |
| `ui/screen/` | MainScreen + HomeScreen + CategoryScreen + SongListScreen + PlayingPanel |
| `ui/components/` | CentralizedDialogHost, MiniPlayer, PlayingComponents, FineTuneComponents, ListItems |
| `viewmodel/` | MainViewModel + LibraryViewModel + SelectionViewModel + PlaylistViewModel + LoopDetectionViewModel + MainViewModelFactory |
| `db/` | AppDatabase + DateConverter + PcDatabaseImporter + PcDatabaseExporter(实体/DAO 已移到 model/) |
| `model/` | 9 个 Room 实体 + 3 个 DAO + Song(Lookup POJO) + `SongMetadataUpdate`(定义在 Song.kt)+ LibraryItem + Folder 等 15 个 `.kt` 文件 |
| `ui/screen/` | MainScreen + MainAppBar + MainTabsPager + PlaylistTabScreen + category/search/settings/songlist 子目录 |
| `ui/components/` | app/common/dialogs 三类组件目录:CentralizedDialogHost, MiniPlayer, PlayingPanel, MultiSelectBar, PlayingComponents, FineTuneComponents, ListItems, LoopCandidatesDialog 等 |
| `ui/state/` | DataUiState 等 UI 数据状态封装 |
| `viewmodel/` | MainViewModel + LibraryViewModel + SelectionViewModel + PlaylistViewModel + LoopDetectionViewModel + MainViewModelFactory + MusicDialog |
| `scanner/` | AudioScanner(Object,MediaStore 扫描;采样点在后续原生层处理) |
| `jni/` | NativeAudio(Kotlin object,JNI 入口)、LoopPoint(原生层返回的数据类) |
| `utils/` | TimeUtils |
Expand All @@ -114,3 +118,5 @@ gradlew.bat connectedAndroidTest # 需要设备

**AB式音乐**在本项目专指一首可循环歌曲分为两个音乐文件,A段是intro,B段是loop,与其他的一首可循环歌曲一个音乐文件不同。
文件名含 `_B`、`_b`、`_loop`、`_Loop` 后缀的文件被标记为 B 段(`isAbPartB=true`),在 UI 列表中默认隐藏。

命名避坑:`model/LoopPoint.kt` 是 Room 实体;`jni/LoopPoint.kt` 是原生循环点分析返回的数据类,二者不要混用。
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe) 混合架构,专门为实现游戏音乐及各种循环音频的完美无缝衔接而设计。

目前由于没有做电脑端同步手机端数据的功能,手机上的任何修改(收藏,歌单,循环点修改等等)都不能同步到电脑,因此最好是只作为电脑端数据的播放器,
现在支持 PC 端数据库导入与手机端数据导出为 PC 端可识别数据库;手机上的循环点、评分、歌单等修改可以通过“导出 PC 端数据库”传回电脑端使用。

![a0e7472702aab23e61a4fb1f948aa075](./image/README/a0e7472702aab23e61a4fb1f948aa075.jpg)

Expand All @@ -19,10 +19,11 @@ Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe)

## 🚀 用户使用指南

### 💻 导入 PC 数据库
### 💻 导入 / 导出 PC 数据库
> [!TIP]
> **点击主界面右上角的奇特符号,然后寻找到电脑端的db文件即可。**
> db 文件可以直接使用微信传输或其他方式,从电脑转移到手机中。导入后,APP 会自动进行容差匹配和增量批量写入,补充手机端没有的循环点、歌单等数据
> **点击主界面右上角的设置入口,进入“数据同步与管理”。**
> - **导入 PC 端数据库**:选择电脑端 `.db` 文件,APP 会自动进行容差匹配和增量批量写入,补充手机端没有的循环点、候选循环点、评分、歌单等数据。
> - **导出 PC 端数据库**:将手机端当前的歌曲元数据、循环点、候选循环点、评分、歌单与队列转换为 PC 端 3NF schema,可传回电脑端继续使用。

下面以微信传输助手为例讲解同步方法:

Expand All @@ -38,6 +39,12 @@ Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe)

![0431d448f108bd3314c14b89944bc93c](./image/README/0431d448f108bd3314c14b89944bc93c.jpg)

导出时在同一设置区域点击 **“导出 PC 端数据库”**,选择保存位置即可。导出的文件名默认为:

```text
seamless_loop_pc_export_yyyyMMdd_HHmmss.db
```




Expand Down Expand Up @@ -76,4 +83,4 @@ Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe)
1. **构建天坑**:`app/build.gradle.kts` 中 `kotlin-android` 插件必须保持注释!
2. **JNI / fopen 路径限制**:Native 音频分析无法直接读取 `content://`,必须先拷贝到私有 cache 目录。
3. **Room Schema**:Room 9张实体表和3个DAO的详细映射图。
4. **包结构速查**:子模块(audio, data, db, viewmodel, model, scanner, jni 等)职责定义。
4. **包结构速查**:子模块(audio, data, db, viewmodel, model, scanner, jni 等)职责定义。
32 changes: 7 additions & 25 deletions app/src/main/cpp/loopfinder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ build/Release/loopfinder_test.exe path/to/audio.flac --no-hpss

# 调整候选网格密度。数值越小,越允许 off-beat 候选
build/Release/loopfinder_test.exe path/to/audio.flac --grid=1

# 关闭局部端点细化(默认半径 6 帧;设为 0 以关闭)
build/Release/loopfinder_test.exe path/to/audio.flac --refine=0
```

### Android NDK (交叉编译)
Expand Down Expand Up @@ -149,33 +152,12 @@ Top N 返回

有意保留的差异:

- PyMusicLooper 使用 librosa beat/PLP;loopfinder 使用 aubio beat,再叠加 fallback grid。默认 `candidateFrameStep=4`,用于让 Top5 候选段更稳定地接近 PyMusicLooper`--grid=1`/`2` 会允许更多 off-beat 候选
- PyMusicLooper 使用 librosa beat/PLP;loopfinder 使用 aubio beat,再叠加 fallback grid。默认 `candidateFrameStep=4`,让默认 Top 候选更稳定;`--grid=2` 可用于诊断/覆盖落在 4 帧网格之间的 PyMusicLooper 候选帧,`--grid=1` 会允许更多 off-beat 候选但候选量更大、剪枝更不稳定
- HPSS 默认开启。批量测试中 HPSS on/off 的分数都接近,但 HPSS on 的 Top5 更稳;可通过 `LoopFinder::Config::useHPSS=false` 或测试参数 `--no-hpss` 关闭。
- PyMusicLooper 当前的 duration priority 基本不会实际重排;loopfinder 为兼容默认关闭 `prioritizeDuration`。

## 批量对比

仓库根目录提供对比脚本:

```bash
python tools/compare_loopfinder.py music --top 20
```

脚本会同时运行 PyMusicLooper、loopfinder 默认 HPSS、loopfinder `--no-hpss`,并打印候选 sample/frame、C++ Top1 与 PyMusicLooper Top 候选中最近点的距离和 score 差异。

PyMusicLooper 结果会缓存在仓库根目录的 `.loop_compare_cache/`。缓存键包含音频路径、文件大小、mtime 和 `--top`,音频不变时不会重复跑 Python 分析。
- PyMusicLooper 会对几乎同分的候选优先选择更长循环段;loopfinder 默认开启 `prioritizeDuration`,可用 `--no-duration-priority` 或 `LoopFinder::Config::prioritizeDuration=false` 关闭。
- loopfinder 默认对 Top50 候选做局部端点细化(`endpointRefineRadius=6`,在候选 beat 帧周围 ±6 帧搜索更优 chroma 相似度位置以微调端点)。可通过 `--refine=0` 或 `LoopFinder::Config::endpointRefineRadius=0` 关闭。PyMusicLooper 无此步骤。
- 端点细化后会对相近候选做 NMS 去重(起止帧均在 12 帧内视作同一簇),避免同一循环点的微小变体挤占 TopN。

刷新缓存:

```bash
python tools/compare_loopfinder.py music --top 20 --refresh-cache
```

可传递 C++ 测试参数:

```bash
python tools/compare_loopfinder.py music/5.flac --top 20 --cpp-arg=--grid=1
```

## 未实现 / 不承担

Expand Down
10 changes: 7 additions & 3 deletions app/src/main/cpp/loopfinder/include/loopfinder/loop_finder.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ class LoopFinder {
int nFFT = 2048;
int hopSize = 512;
// Dense grid fallback for PyMusicLooper-like PLP beat coverage.
// Lower values allow more off-beat candidates; 4 is steadier for Top5 alignment.
// Step 4 keeps the default candidate set stable; use --grid=2 when
// diagnosing tracks whose PyMusicLooper beat frames fall between grid points.
int candidateFrameStep = 4;
bool useHPSS = true;
bool prioritizeDuration = false;
bool prioritizeDuration = true;
// 对已入围 top 候选做局部端点细化半径(帧数),0 关闭
int endpointRefineRadius = 6;
};

LoopFinder() = default;
Expand All @@ -35,7 +38,8 @@ class LoopFinder {

void scoreCandidates(const std::vector<std::vector<float>>& chroma,
float bpm, int hopSize, int sampleRate,
std::vector<LoopPoint>& candidates);
std::vector<LoopPoint>& candidates,
int endpointRefineRadius);

void prioritizeDuration(std::vector<LoopPoint>& candidates);

Expand Down
Loading