From 723690d240cec2292614e9096d62201a05e87a5b Mon Sep 17 00:00:00 2001 From: daititii Date: Fri, 5 Jun 2026 18:35:46 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=E4=BF=AE=E6=AD=A3=20PowerDB=20?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/cpp/loopfinder/README.md | 32 +---- .../include/loopfinder/loop_finder.h | 10 +- .../main/cpp/loopfinder/src/loop_finder.cpp | 134 +++++++++++++++++- .../main/cpp/loopfinder/test/test_main.cpp | 5 + 4 files changed, 146 insertions(+), 35 deletions(-) diff --git a/app/src/main/cpp/loopfinder/README.md b/app/src/main/cpp/loopfinder/README.md index 2cec965..6a5b336 100644 --- a/app/src/main/cpp/loopfinder/README.md +++ b/app/src/main/cpp/loopfinder/README.md @@ -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 (交叉编译) @@ -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 -``` ## 未实现 / 不承担 diff --git a/app/src/main/cpp/loopfinder/include/loopfinder/loop_finder.h b/app/src/main/cpp/loopfinder/include/loopfinder/loop_finder.h index 17c0e00..73b845f 100644 --- a/app/src/main/cpp/loopfinder/include/loopfinder/loop_finder.h +++ b/app/src/main/cpp/loopfinder/include/loopfinder/loop_finder.h @@ -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; @@ -35,7 +38,8 @@ class LoopFinder { void scoreCandidates(const std::vector>& chroma, float bpm, int hopSize, int sampleRate, - std::vector& candidates); + std::vector& candidates, + int endpointRefineRadius); void prioritizeDuration(std::vector& candidates); diff --git a/app/src/main/cpp/loopfinder/src/loop_finder.cpp b/app/src/main/cpp/loopfinder/src/loop_finder.cpp index c1e973d..29be7ae 100644 --- a/app/src/main/cpp/loopfinder/src/loop_finder.cpp +++ b/app/src/main/cpp/loopfinder/src/loop_finder.cpp @@ -229,7 +229,8 @@ void LoopFinder::findCandidatePairs(const std::vector>& chrom void LoopFinder::scoreCandidates(const std::vector>& chroma, float bpm, int hopSize, int sampleRate, - std::vector& candidates) { + std::vector& candidates, + int endpointRefineRadius) { LF_LOG("[scoreCandidates] candidates=%zu bpm=%.1f", candidates.size(), bpm); if (candidates.empty()) return; @@ -296,6 +297,45 @@ void LoopFinder::scoreCandidates(const std::vector>& chroma, std::vector lookbehindBuf(testOffsetFrames); std::vector padded(testOffsetFrames); + // Local scoring lambda – re-uses the same lookahead/lookbehind chroma similarity logic. + auto computeScore = [&](int b1, int b2) -> float { + if (b1 < 0 || b2 < 0 || b1 >= numChromaFrames || b2 >= numChromaFrames || b2 <= b1) + return -1.0f; + int b1End = std::min(b1 + testOffsetFrames, numChromaFrames); + int b2End = std::min(b2 + testOffsetFrames, numChromaFrames); + int maxOffset = std::min(b1End - b1, b2End - b2); + float laScore = 0.0f; + if (maxOffset > 0) { + float wSum = 0.0f; + for (int i = 0; i < maxOffset; ++i) { + const float* ca = &normChroma[static_cast(b1 + i) * 12]; + const float* cb = &normChroma[static_cast(b2 + i) * 12]; + float d = dotProduct(ca, cb, 12); + float w = (i < testOffsetFrames) ? weights[i] : 0.0f; + laScore += d * w; + wSum += w; + } + if (wSum > 0.0f) laScore /= wSum; + } + int maxNeg = std::min(testOffsetFrames, std::min(b1, b2)); + int b1s = b1 - maxNeg; + int b2s = b2 - maxNeg; + float lbScore = 0.0f; + if (maxNeg > 0) { + float wSum = 0.0f; + for (int i = 0; i < maxNeg; ++i) { + const float* ca = &normChroma[static_cast(b1s + i) * 12]; + const float* cb = &normChroma[static_cast(b2s + i) * 12]; + float d = dotProduct(ca, cb, 12); + float w = (i < testOffsetFrames) ? revWeights[i] : 0.0f; + lbScore += d * w; + wSum += w; + } + if (wSum > 0.0f) lbScore /= wSum; + } + return std::max(laScore, lbScore); + }; + for (auto& lp : candidates) { int b1 = static_cast(lp.loopStart); int b2 = static_cast(lp.loopEnd); @@ -370,6 +410,83 @@ void LoopFinder::scoreCandidates(const std::vector>& chroma, [](const LoopPoint& a, const LoopPoint& b) { return a.score > b.score; }); + + // ---- Local endpoint refinement for top candidates ---- + if (endpointRefineRadius > 0) { + const int topK = std::min(50, static_cast(candidates.size())); + const int searchRadius = std::max(0, endpointRefineRadius); + for (int i = 0; i < topK; ++i) { + auto& lp = candidates[i]; + int origB1 = static_cast(lp.loopStart); + int origB2 = static_cast(lp.loopEnd); + int bestB1 = origB1; + int bestB2 = origB2; + float originalScore = lp.score; + float bestScore = lp.score; + for (int dB1 = -searchRadius; dB1 <= searchRadius; ++dB1) { + for (int dB2 = -searchRadius; dB2 <= searchRadius; ++dB2) { + if (dB1 == 0 && dB2 == 0) continue; + int nb1 = origB1 + dB1; + int nb2 = origB2 + dB2; + if (nb1 < 0 || nb2 >= numChromaFrames || nb2 <= nb1) continue; + float s = computeScore(nb1, nb2); + if (s > bestScore) { + bestScore = s; + bestB1 = nb1; + bestB2 = nb2; + } + } + } + if (bestScore > lp.score) { + lp.loopStart = bestB1; + lp.loopEnd = bestB2; + lp.loopStartFrame = bestB1; + lp.loopEndFrame = bestB2; + // Keep the original ranking score: refinement should improve + // endpoint placement without letting local score bumps reorder + // unrelated candidates ahead of musically preferred loops. + lp.score = originalScore; + // recompute noteDiff (Euclidean distance on raw chroma endpoints) + float nd = 0.0f; + for (int c = 0; c < 12; ++c) { + float diff = chroma[c][bestB1] - chroma[c][bestB2]; + nd += diff * diff; + } + lp.noteDiff = std::sqrt(nd); + // loudnessDiff intentionally left unchanged + } + } + } + + // ---- Near-duplicate NMS (non-maximum suppression) ---- + // Sort so the best candidate in each (start,end) cluster comes first. + // Then keep the highest-scoring candidate and skip any other candidate + // whose loopStartFrame and loopEndFrame are both within nmsFrameRadius + // of an already-kept candidate. + const int nmsFrameRadius = 12; + std::sort(candidates.begin(), candidates.end(), + [](const LoopPoint& a, const LoopPoint& b) { + if (a.score != b.score) return a.score > b.score; + if (a.loudnessDiff != b.loudnessDiff) return a.loudnessDiff < b.loudnessDiff; + return a.noteDiff < b.noteDiff; + }); + std::vector kept; + kept.reserve(candidates.size()); + for (const auto& lp : candidates) { + bool nearDuplicate = false; + for (const auto& kp : kept) { + if (std::abs(lp.loopStartFrame - kp.loopStartFrame) <= nmsFrameRadius && + std::abs(lp.loopEndFrame - kp.loopEndFrame) <= nmsFrameRadius) { + nearDuplicate = true; + break; + } + } + if (!nearDuplicate) { + kept.push_back(lp); + } + } + candidates = std::move(kept); + LF_LOG("[scoreCandidates] done topScore=%.4f", candidates.empty() ? 0.0f : candidates[0].score); } @@ -389,7 +506,8 @@ void LoopFinder::prioritizeDuration(std::vector& candidates) { auto score90 = scoreVals.begin() + scoreVals.size() * 9 / 10; std::nth_element(scoreVals.begin(), score90, scoreVals.end()); - float scoreThreshold = std::max(*score90, candidates[0].score - 1e-4f); + const float durationScoreTolerance = 1e-3f; // Allow nearly-equal-scoring longer candidates + float scoreThreshold = std::max(*score90, candidates[0].score - durationScoreTolerance); float dbThreshold = 0.25f; if (!loudVals.empty()) { @@ -513,14 +631,15 @@ std::vector LoopFinder::analyze(const float* monoSignal, int signalLe } } - float medianRef = 1.0f; + float rawMedian = 0.0f; if (!allVals.empty()) { auto mid = allVals.begin() + allVals.size() / 2; std::nth_element(allVals.begin(), mid, allVals.end()); - medianRef = std::abs(*mid); + rawMedian = *mid; // raw median in dB domain (may be negative) } - medianRef = std::max(medianRef, 1e-10f); - LF_LOG("[analyze] step 4: weighted median abs=%.6f", medianRef); + const float amin = 1e-10f; + float medianRef = std::max(rawMedian, amin); + LF_LOG("[analyze] step 4: weighted median raw=%.6f clippedRef=%.6f", rawMedian, medianRef); float maxDb = -1e30f; for (int k = 0; k < numFreqBins; ++k) { @@ -618,7 +737,8 @@ std::vector LoopFinder::analyze(const float* monoSignal, int signalLe // 8. Score candidates LF_LOG("[analyze] step 8: scoreCandidates"); - scoreCandidates(chromagram, bpm, config.hopSize, sampleRate, candidates); + scoreCandidates(chromagram, bpm, config.hopSize, sampleRate, candidates, + config.endpointRefineRadius); t1 = std::chrono::high_resolution_clock::now(); LF_PERF("scoreCandidates", std::chrono::duration_cast(t1 - t0).count()); t0 = t1; diff --git a/app/src/main/cpp/loopfinder/test/test_main.cpp b/app/src/main/cpp/loopfinder/test/test_main.cpp index b81d309..01be256 100644 --- a/app/src/main/cpp/loopfinder/test/test_main.cpp +++ b/app/src/main/cpp/loopfinder/test/test_main.cpp @@ -32,12 +32,17 @@ int main(int argc, char** argv) { std::string arg = argv[i]; if (arg == "--no-hpss") config.useHPSS = false; if (arg == "--duration-priority") config.prioritizeDuration = true; + if (arg == "--no-duration-priority") config.prioritizeDuration = false; if (arg.rfind("--grid=", 0) == 0) config.candidateFrameStep = std::stoi(arg.substr(7)); + if (arg.rfind("--top=", 0) == 0) config.topN = std::stoi(arg.substr(6)); + if (arg.rfind("--refine=", 0) == 0) config.endpointRefineRadius = std::stoi(arg.substr(9)); } std::cout << "config: useHPSS=" << (config.useHPSS ? "true" : "false") << " prioritizeDuration=" << (config.prioritizeDuration ? "true" : "false") << " candidateFrameStep=" << config.candidateFrameStep + << " topN=" << config.topN + << " endpointRefineRadius=" << config.endpointRefineRadius << "\n"; auto results = finder.analyze( From e5998e0f77abcd72daf465272aa9e4ffd27cf6e3 Mon Sep 17 00:00:00 2001 From: daititii Date: Sat, 6 Jun 2026 16:36:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20PC=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=8F=8C=E5=90=91=E5=90=8C=E6=AD=A5=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 31 +- README.md | 17 +- .../cpu/seamlessloopmobile/MainActivity.kt | 58 ++- .../db/PcDatabaseExporter.kt | 418 ++++++++++++++++++ .../db/PcDatabaseImporter.kt | 76 +++- .../com/cpu/seamlessloopmobile/model/Song.kt | 5 +- .../cpu/seamlessloopmobile/model/SongDao.kt | 5 + .../ui/screen/MainScreen.kt | 6 +- .../ui/screen/settings/SettingsDrawer.kt | 4 +- .../ui/screen/settings/SettingsScreen.kt | 30 +- ...45\344\270\216\345\257\274\345\207\272.md" | 86 ++++ 11 files changed, 703 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseExporter.kt create mode 100644 "docs/2026-06-06_PC\346\225\260\346\215\256\345\272\223\345\217\214\345\220\221\345\220\214\346\255\245\344\270\216\345\257\274\345\207\272.md" diff --git a/AGENTS.md b/AGENTS.md index c04a597..a7c5649 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ Android 无缝循环音频播放器,Kotlin + C++ (Oboe) 混合架构。 单模块项目 (`:app`),Min SDK 26,Compile/Target SDK 35。 Kotlin 2.1.0 + AGP 9.0.1 + Gradle 9.1.0,Compose compiler 由 Kotlin 2.1.0 内置。 +远端仓库:`https://github.com/daititii/SeamlessLoopMobileNative.git`(GitHub MCP 可用时优先用 MCP 查询 PR/Issue/远端文件状态)。 ## 构建与测试 @@ -23,9 +24,9 @@ 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) @@ -33,7 +34,7 @@ gradlew.bat connectedAndroidTest # 需要设备 - 统一通过 `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` 集中管理 @@ -65,15 +66,17 @@ gradlew.bat connectedAndroidTest # 需要设备 - `MusicRepository` — Facade,聚合 3 个子 Repository + PlayQueueDao - `SongRepository` — 歌曲 CRUD -- `PlaylistRepository` — 播放列表基础 CRUD(A/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` 键名。 ## 开发注意事项 @@ -92,6 +95,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` 块中立即彻底删除临时文件,防止磁盘残留。 @@ -101,11 +105,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 | @@ -114,3 +119,5 @@ gradlew.bat connectedAndroidTest # 需要设备 **AB式音乐**在本项目专指一首可循环歌曲分为两个音乐文件,A段是intro,B段是loop,与其他的一首可循环歌曲一个音乐文件不同。 文件名含 `_B`、`_b`、`_loop`、`_Loop` 后缀的文件被标记为 B 段(`isAbPartB=true`),在 UI 列表中默认隐藏。 + +命名避坑:`model/LoopPoint.kt` 是 Room 实体;`jni/LoopPoint.kt` 是原生循环点分析返回的数据类,二者不要混用。 diff --git a/README.md b/README.md index 717dc09..7f5ed64 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe) 混合架构,专门为实现游戏音乐及各种循环音频的完美无缝衔接而设计。 -目前由于没有做电脑端同步手机端数据的功能,手机上的任何修改(收藏,歌单,循环点修改等等)都不能同步到电脑,因此最好是只作为电脑端数据的播放器, +现在支持 PC 端数据库导入与手机端数据导出为 PC 端可识别数据库;手机上的循环点、评分、歌单等修改可以通过“导出 PC 端数据库”传回电脑端使用。 ![a0e7472702aab23e61a4fb1f948aa075](./image/README/a0e7472702aab23e61a4fb1f948aa075.jpg) @@ -19,10 +19,11 @@ Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe) ## 🚀 用户使用指南 -### 💻 导入 PC 数据库 +### 💻 导入 / 导出 PC 数据库 > [!TIP] -> **点击主界面右上角的奇特符号,然后寻找到电脑端的db文件即可。** -> db 文件可以直接使用微信传输或其他方式,从电脑转移到手机中。导入后,APP 会自动进行容差匹配和增量批量写入,补充手机端没有的循环点、歌单等数据 +> **点击主界面右上角的设置入口,进入“数据同步与管理”。** +> - **导入 PC 端数据库**:选择电脑端 `.db` 文件,APP 会自动进行容差匹配和增量批量写入,补充手机端没有的循环点、候选循环点、评分、歌单等数据。 +> - **导出 PC 端数据库**:将手机端当前的歌曲元数据、循环点、候选循环点、评分、歌单与队列转换为 PC 端 3NF schema,可传回电脑端继续使用。 下面以微信传输助手为例讲解同步方法: @@ -38,6 +39,12 @@ Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe) ![0431d448f108bd3314c14b89944bc93c](./image/README/0431d448f108bd3314c14b89944bc93c.jpg) +导出时在同一设置区域点击 **“导出 PC 端数据库”**,选择保存位置即可。导出的文件名默认为: + +```text +seamless_loop_pc_export_yyyyMMdd_HHmmss.db +``` + @@ -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 等)职责定义。 \ No newline at end of file +4. **包结构速查**:子模块(audio, data, db, viewmodel, model, scanner, jni 等)职责定义。 diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/MainActivity.kt b/app/src/main/java/com/cpu/seamlessloopmobile/MainActivity.kt index 403d599..39ae531 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/MainActivity.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/MainActivity.kt @@ -22,6 +22,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import com.cpu.seamlessloopmobile.ui.screen.MainScreen import com.cpu.seamlessloopmobile.data.SettingsManager +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** * 精简后的 MainActivity 喵! @@ -39,6 +42,11 @@ class MainActivity : ComponentActivity() { uri?.let { importFromPcDatabase(it) } } + // PC 兼容数据库导出保存器喵:交给系统文件选择器决定导出位置,不额外索要写存储权限。 + private val dbExportLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> + uri?.let { exportPcDatabaseToUri(it) } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -59,7 +67,8 @@ class MainActivity : ComponentActivity() { MainScreen( viewModel = viewModel, playSong = { song -> viewModel.playSong(song) }, - onSyncPc = { dbPickerLauncher.launch("application/octet-stream") } + onSyncPc = { dbPickerLauncher.launch("application/octet-stream") }, + onExportDatabase = { dbExportLauncher.launch(createDatabaseExportFileName()) } ) } } @@ -133,4 +142,49 @@ class MainActivity : ComponentActivity() { ) } } -} \ No newline at end of file + + private fun createDatabaseExportFileName(): String { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + return "seamless_loop_pc_export_$timestamp.db" + } + + private fun exportPcDatabaseToUri(uri: android.net.Uri) { + lifecycleScope.launch(Dispatchers.IO) { + try { + val database = com.cpu.seamlessloopmobile.db.AppDatabase.getDatabase(this@MainActivity) + val result = com.cpu.seamlessloopmobile.db.PcDatabaseExporter.exportToPcDatabase( + context = this@MainActivity, + uri = uri, + songDao = database.songDao(), + playlistDao = database.playlistDao(), + playQueueDao = database.playQueueDao() + ) + + runOnUiThread { + Toast.makeText( + this@MainActivity, + "PC 端数据库导出完成喵!${result.trackCount} 首歌、${result.playlistCount} 个歌单,大小 ${formatBytes(result.bytes)}", + Toast.LENGTH_LONG + ).show() + } + } catch (e: Exception) { + android.util.Log.e("MainActivity", "PC database export failed", e) + runOnUiThread { + Toast.makeText( + this@MainActivity, + "PC 端数据库导出失败了(>_<): ${e.message}。若正在扫描,请稍后重试喵", + Toast.LENGTH_LONG + ).show() + } + } + } + } + + private fun formatBytes(bytes: Long): String { + return when { + bytes >= 1024L * 1024L -> String.format(Locale.US, "%.2f MB", bytes / 1024.0 / 1024.0) + bytes >= 1024L -> String.format(Locale.US, "%.1f KB", bytes / 1024.0) + else -> "$bytes B" + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseExporter.kt b/app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseExporter.kt new file mode 100644 index 0000000..0ffc8d1 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseExporter.kt @@ -0,0 +1,418 @@ +package com.cpu.seamlessloopmobile.db + +import android.content.Context +import android.net.Uri +import android.database.sqlite.SQLiteDatabase +import com.cpu.seamlessloopmobile.model.PlayQueueDao +import com.cpu.seamlessloopmobile.model.PlaylistDao +import com.cpu.seamlessloopmobile.model.Song +import com.cpu.seamlessloopmobile.model.SongDao +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 手机端 → PC 端数据库导出器喵! + * + * 重要:这里不是简单复制 Room 数据库。手机端主表名是 Songs,而 PC 端 3NF 数据库使用 Tracks, + * LoopPoints/UserRatings 外键列也叫 TrackId。为了让电脑端能够直接打开/同步,必须生成一份 + * PC 端可识别的 SQLite 文件,再写入用户通过 SAF 选择的导出位置。 + */ +object PcDatabaseExporter { + + data class ExportResult( + val trackCount: Int, + val playlistCount: Int, + val bytes: Long + ) + + var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + suspend fun exportToPcDatabase( + context: Context, + uri: Uri, + songDao: SongDao, + playlistDao: PlaylistDao, + playQueueDao: PlayQueueDao + ): ExportResult = withContext(ioDispatcher) { + val tempFile = File(context.cacheDir, "export_pc_data_${System.currentTimeMillis()}.db") + + try { + if (tempFile.exists()) tempFile.delete() + + val allSongs = songDao.getAllSongsRaw() + .sortedWith(compareBy { it.fileName.lowercase() }.thenBy { it.id }) + val playlists = playlistDao.getPlaylistsWithCounts().map { it.playlist } + val playQueueSongs = playQueueDao.getPlayQueueSongs() + val exportedPcTrackIds = mutableSetOf() + + val exportDb = SQLiteDatabase.openOrCreateDatabase(tempFile, null) + var transactionStarted = false + try { + exportDb.execSQL("PRAGMA foreign_keys = ON") + exportDb.beginTransaction() + transactionStarted = true + createPcSchema(exportDb) + + // PC 端通过 Tracks.Id 串起 LoopPoints/UserRatings/PlaylistItems。 + // 为了避免手机端 Room 自增 ID 的空洞或冲突影响 PC,导出时重新分配连续 TrackId。 + val mobileSongIdToPcTrackId = mutableMapOf() + val artistNameToPcId = mutableMapOf() + val albumNameToPcId = mutableMapOf() + + allSongs.forEachIndexed { index, song -> + val artistId = insertArtistIfNeeded(exportDb, song, artistNameToPcId) + val albumId = insertAlbumIfNeeded(exportDb, song, albumNameToPcId) + val track = insertTrack(exportDb, song, artistId, albumId) + mobileSongIdToPcTrackId[song.id] = track.id + + // PC 端 Tracks 有 UNIQUE(FileName, TotalSamples)。如果手机端存在同名同采样数的重复记录, + // PC schema 无法一比一表达,只能合并到第一条导出的 Track 上,避免导出直接失败。 + if (track.inserted) { + exportedPcTrackIds.add(track.id) + insertLoopPoint(exportDb, song, track.id) + insertUserRating(exportDb, song, track.id) + } + + // 顺便把手机端歌曲所在文件夹写入 MusicFolders,方便 PC 端初次打开时有扫描根目录参考。 + insertMusicFolderIfNeeded(exportDb, song) + + if ((index + 1) % 200 == 0) { + android.util.Log.d("PcDatabaseExporter", "已导出 ${index + 1}/${allSongs.size} 首歌曲喵") + } + } + + val usedPlaylistNames = mutableSetOf() + playlists.forEach { playlist -> + val pcPlaylistName = makeUniquePlaylistName(playlist.name, usedPlaylistNames) + val pcPlaylistId = insertPlaylist(exportDb, pcPlaylistName, playlist.sortOrder, playlist.createdAt) + val songsInPlaylist = playlistDao.getSongsInPlaylist(playlist.id) + songsInPlaylist.forEachIndexed { index, song -> + val pcTrackId = mobileSongIdToPcTrackId[song.id] ?: return@forEachIndexed + insertPlaylistItem(exportDb, pcPlaylistId, pcTrackId, index + 1) + } + } + + playQueueSongs.forEachIndexed { index, song -> + val pcTrackId = mobileSongIdToPcTrackId[song.id] ?: return@forEachIndexed + insertQueuedTrack(exportDb, pcTrackId, index) + } + + insertDefaultAppSettings(exportDb) + exportDb.setTransactionSuccessful() + } finally { + if (transactionStarted) exportDb.endTransaction() + exportDb.close() + } + + val exportedBytes = tempFile.length() + tempFile.inputStream().use { input -> + // "wt" = write + truncate:如果用户选了一个已存在的文件,就直接覆盖写入喵。 + val output = context.contentResolver.openOutputStream(uri, "wt") + ?: throw IllegalStateException("无法打开导出文件") + output.use { target -> + input.copyTo(target) + target.flush() + } + } + + ExportResult( + trackCount = exportedPcTrackIds.size, + playlistCount = playlists.size, + bytes = exportedBytes + ) + } finally { + // 临时 PC 数据库只作为中转文件使用,写入 SAF 目标后必须立即清掉喵。 + tempFile.delete() + } + } + + private fun createPcSchema(db: SQLiteDatabase) { + db.execSQL(""" + CREATE TABLE Artists ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT NOT NULL UNIQUE, + CoverPath TEXT + ) + """.trimIndent()) + + db.execSQL(""" + CREATE TABLE Albums ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT NOT NULL, + CoverPath TEXT, + UNIQUE(Name) + ) + """.trimIndent()) + + db.execSQL(""" + CREATE TABLE Tracks ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + FileName TEXT NOT NULL, + FilePath TEXT, + DisplayName TEXT, + TotalSamples INTEGER DEFAULT 0, + LastModified DATETIME, + CoverPath TEXT, + AlbumId INTEGER, + ArtistId INTEGER, + FOREIGN KEY(AlbumId) REFERENCES Albums(Id) ON DELETE SET NULL, + FOREIGN KEY(ArtistId) REFERENCES Artists(Id) ON DELETE SET NULL, + UNIQUE(FileName, TotalSamples) + ) + """.trimIndent()) + + db.execSQL(""" + CREATE TABLE LoopPoints ( + TrackId INTEGER PRIMARY KEY, + LoopStart INTEGER DEFAULT 0, + LoopEnd INTEGER DEFAULT 0, + LoopCandidatesJson TEXT, + AnalysisLastModified DATETIME, + FOREIGN KEY(TrackId) REFERENCES Tracks(Id) ON DELETE CASCADE + ) + """.trimIndent()) + + db.execSQL(""" + CREATE TABLE UserRatings ( + TrackId INTEGER PRIMARY KEY, + Rating INTEGER DEFAULT 0, + LastModified DATETIME, + FOREIGN KEY(TrackId) REFERENCES Tracks(Id) ON DELETE CASCADE + ) + """.trimIndent()) + + db.execSQL(""" + CREATE TABLE Playlists ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT NOT NULL, + SortOrder INTEGER DEFAULT 0, + CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """.trimIndent()) + + db.execSQL(""" + CREATE TABLE PlaylistItems ( + PlaylistId INTEGER, + SongId INTEGER, + SortOrder INTEGER DEFAULT 0, + PRIMARY KEY(PlaylistId, SongId), + FOREIGN KEY(PlaylistId) REFERENCES Playlists(Id) ON DELETE CASCADE, + FOREIGN KEY(SongId) REFERENCES Tracks(Id) ON DELETE CASCADE + ) + """.trimIndent()) + + db.execSQL("CREATE TABLE MusicFolders (Id INTEGER PRIMARY KEY AUTOINCREMENT, FolderPath TEXT NOT NULL UNIQUE, AddedAt DATETIME DEFAULT CURRENT_TIMESTAMP)") + db.execSQL("CREATE TABLE AppSettings (Key TEXT PRIMARY KEY, Value TEXT)") + db.execSQL("CREATE TABLE QueuedTracks (Id INTEGER PRIMARY KEY AUTOINCREMENT, TrackId INTEGER, SortOrder INTEGER)") + + db.execSQL("CREATE INDEX idx_tracks_albumid ON Tracks(AlbumId)") + db.execSQL("CREATE INDEX idx_tracks_artistid ON Tracks(ArtistId)") + db.execSQL("CREATE UNIQUE INDEX idx_playlists_name ON Playlists(Name)") + db.execSQL("CREATE INDEX idx_playlistitems_songid ON PlaylistItems(SongId)") + } + + private fun insertArtistIfNeeded( + db: SQLiteDatabase, + song: Song, + cache: MutableMap + ): Long? { + val name = song.artist.takeUnless { it.isBlank() || it == "Unknown Artist" } ?: return null + return cache.getOrPut(name.lowercase()) { + val values = android.content.ContentValues().apply { + put("Name", name) + put("CoverPath", song.artistEntity?.coverPath) + } + db.insertWithOnConflict("Artists", null, values, SQLiteDatabase.CONFLICT_IGNORE).let { insertedId -> + if (insertedId != -1L) insertedId else queryIdByName(db, "Artists", name) + } + } + } + + private fun insertAlbumIfNeeded( + db: SQLiteDatabase, + song: Song, + cache: MutableMap + ): Long? { + val name = song.album.takeUnless { it.isBlank() || it == "Unknown Album" } ?: return null + return cache.getOrPut(name.lowercase()) { + val values = android.content.ContentValues().apply { + put("Name", name) + put("CoverPath", song.albumEntity?.coverPath ?: song.coverPath) + } + db.insertWithOnConflict("Albums", null, values, SQLiteDatabase.CONFLICT_IGNORE).let { insertedId -> + if (insertedId != -1L) insertedId else queryIdByName(db, "Albums", name) + } + } + } + + private data class TrackInsertResult( + val id: Long, + val inserted: Boolean + ) + + private fun insertTrack(db: SQLiteDatabase, song: Song, artistId: Long?, albumId: Long?): TrackInsertResult { + val values = android.content.ContentValues().apply { + put("FileName", song.fileName) + put("FilePath", song.filePath) + put("DisplayName", song.displayName) + put("TotalSamples", song.totalSamples) + put("LastModified", toPcDateTime(song.lastModified)) + put("CoverPath", song.coverPath) + if (albumId != null) put("AlbumId", albumId) else putNull("AlbumId") + if (artistId != null) put("ArtistId", artistId) else putNull("ArtistId") + } + val insertedId = db.insertWithOnConflict("Tracks", null, values, SQLiteDatabase.CONFLICT_IGNORE) + if (insertedId != -1L) return TrackInsertResult(insertedId, inserted = true) + + db.rawQuery( + "SELECT Id FROM Tracks WHERE FileName = ? AND TotalSamples = ? LIMIT 1", + arrayOf(song.fileName, song.totalSamples.toString()) + ).use { cursor -> + if (cursor.moveToFirst()) return TrackInsertResult(cursor.getLong(0), inserted = false) + } + + throw IllegalStateException("无法写入或定位导出的曲目:${song.fileName}") + } + + private fun insertLoopPoint(db: SQLiteDatabase, song: Song, trackId: Long) { + val values = android.content.ContentValues().apply { + put("TrackId", trackId) + put("LoopStart", song.loopStart) + put("LoopEnd", song.loopEnd) + // 手机端 Gson 字段是 loopStart/loopEnd/noteDiff/score,PC 样本库使用 + // LoopStart/LoopEnd/NoteDifference/Score。这里顺手转成 PC 端更容易识别的键名喵。 + put("LoopCandidatesJson", toPcLoopCandidatesJson(song.loopCandidatesJson)) + putNull("AnalysisLastModified") + } + db.insertWithOnConflict("LoopPoints", null, values, SQLiteDatabase.CONFLICT_REPLACE) + } + + private fun insertUserRating(db: SQLiteDatabase, song: Song, trackId: Long) { + val values = android.content.ContentValues().apply { + put("TrackId", trackId) + put("Rating", song.rating) + put("LastModified", toPcDateTime(song.userRating?.lastModified ?: song.lastModified)) + } + db.insertWithOnConflict("UserRatings", null, values, SQLiteDatabase.CONFLICT_REPLACE) + } + + private fun insertPlaylist(db: SQLiteDatabase, name: String, sortOrder: Int, createdAt: Long): Long { + val values = android.content.ContentValues().apply { + put("Name", name) + put("SortOrder", sortOrder) + put("CreatedAt", toPcDateTime(createdAt)) + } + return db.insertOrThrow("Playlists", null, values) + } + + private fun makeUniquePlaylistName(rawName: String, usedNames: MutableSet): String { + val baseName = rawName.takeIf { it.isNotBlank() } ?: "未命名歌单" + var candidate = baseName + var suffix = 2 + while (!usedNames.add(candidate)) { + candidate = "$baseName ($suffix)" + suffix++ + } + return candidate + } + + private fun insertPlaylistItem(db: SQLiteDatabase, playlistId: Long, trackId: Long, sortOrder: Int) { + val values = android.content.ContentValues().apply { + put("PlaylistId", playlistId) + put("SongId", trackId) + put("SortOrder", sortOrder) + } + db.insertWithOnConflict("PlaylistItems", null, values, SQLiteDatabase.CONFLICT_IGNORE) + } + + private fun insertQueuedTrack(db: SQLiteDatabase, trackId: Long, sortOrder: Int) { + val values = android.content.ContentValues().apply { + put("TrackId", trackId) + put("SortOrder", sortOrder) + } + db.insert("QueuedTracks", null, values) + } + + private fun insertMusicFolderIfNeeded(db: SQLiteDatabase, song: Song) { + val parent = File(song.filePath).parent ?: return + if (parent.isBlank()) return + val values = android.content.ContentValues().apply { + put("FolderPath", parent) + put("AddedAt", toPcDateTime(System.currentTimeMillis())) + } + db.insertWithOnConflict("MusicFolders", null, values, SQLiteDatabase.CONFLICT_IGNORE) + } + + private fun insertDefaultAppSettings(db: SQLiteDatabase) { + // 只写入最基础的版本标记,避免伪造 PC 端播放状态导致打开后跳到不存在的分类或曲目。 + val values = android.content.ContentValues().apply { + put("Key", "MobileExport.Schema") + put("Value", "pc_3nf_v1") + } + db.insertWithOnConflict("AppSettings", null, values, SQLiteDatabase.CONFLICT_REPLACE) + } + + private fun queryIdByName(db: SQLiteDatabase, table: String, name: String): Long { + db.rawQuery("SELECT Id FROM $table WHERE Name = ? LIMIT 1", arrayOf(name)).use { cursor -> + if (cursor.moveToFirst()) return cursor.getLong(0) + } + throw IllegalStateException("无法读取 $table 中的 $name") + } + + private fun toPcLoopCandidatesJson(json: String?): String? { + val source = json?.takeIf { it.isNotBlank() && it != "[]" && it != "{}" } ?: return json + + return try { + val input = JSONArray(source) + val output = JSONArray() + for (i in 0 until input.length()) { + val item = input.optJSONObject(i) ?: continue + val pcItem = JSONObject().apply { + put("LoopStart", readLong(item, "LoopStart", "loopStart")) + put("LoopEnd", readLong(item, "LoopEnd", "loopEnd")) + put("Score", readDouble(item, "Score", "score")) + put("NoteDifference", readDouble(item, "NoteDifference", "noteDiff")) + + // PC 旧样本不一定带 LoudnessDifference,但移动端有 loudnessDiff;保留下来不影响兼容。 + if (hasAny(item, "LoudnessDifference", "loudnessDiff")) { + put("LoudnessDifference", readDouble(item, "LoudnessDifference", "loudnessDiff")) + } + } + output.put(pcItem) + } + output.toString() + } catch (_: Exception) { + // 若将来 JSON 结构又变了,至少不要破坏原始缓存,原样交给 PC 端自己尝试解析喵。 + json + } + } + + private fun readLong(item: JSONObject, vararg keys: String): Long { + keys.forEach { key -> + if (item.has(key) && !item.isNull(key)) return item.optLong(key) + } + return 0L + } + + private fun readDouble(item: JSONObject, vararg keys: String): Double { + keys.forEach { key -> + if (item.has(key) && !item.isNull(key)) return item.optDouble(key) + } + return 0.0 + } + + private fun hasAny(item: JSONObject, vararg keys: String): Boolean { + return keys.any { key -> item.has(key) && !item.isNull(key) } + } + + private fun toPcDateTime(timestamp: Long): String { + return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(timestamp)) + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseImporter.kt b/app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseImporter.kt index ce11ca9..62bcd9c 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseImporter.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseImporter.kt @@ -8,6 +8,8 @@ import java.io.File import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject /** * 后勤搬运工:负责从 PC 端导出的 SQLite 数据库中提取循环数据, 并在本地数据库中打下“灵魂锚点”。 @@ -78,7 +80,8 @@ object PcDatabaseImporter { """ SELECT t.FileName, t.TotalSamples, lp.LoopStart, lp.LoopEnd, t.DisplayName, - ar.Name AS Artist, al.Name AS Album, ur.Rating, t.CoverPath + ar.Name AS Artist, al.Name AS Album, ur.Rating, t.CoverPath, + lp.LoopCandidatesJson FROM Tracks t LEFT JOIN LoopPoints lp ON t.Id = lp.TrackId LEFT JOIN UserRatings ur ON t.Id = ur.TrackId @@ -86,7 +89,7 @@ object PcDatabaseImporter { LEFT JOIN Albums al ON t.AlbumId = al.Id """.trimIndent() } else { - "SELECT FileName, TotalSamples, LoopStart, LoopEnd, DisplayName, Artist, Album, Rating, CoverPath FROM LoopPoints" + "SELECT FileName, TotalSamples, LoopStart, LoopEnd, DisplayName, Artist, Album, Rating, CoverPath, NULL AS LoopCandidatesJson FROM LoopPoints" } val cursor = extDb.rawQuery(query, null) @@ -103,7 +106,8 @@ object PcDatabaseImporter { artist = cursor.getString(5), album = cursor.getString(6), rating = cursor.getInt(7), - coverPath = cursor.getString(8) + coverPath = cursor.getString(8), + loopCandidatesJson = toMobileLoopCandidatesJson(cursor.getString(9)) ) extData.add(data) // 预收集所有可能缺失的分类名 @@ -189,15 +193,21 @@ object PcDatabaseImporter { } if (matchedSong != null) { + // 对齐 PC 端 SyncWithExternalDatabase 的保护策略: + // 1) 外部库没有实质循环点成果时,不允许 0/0 覆盖手机端已经探测好的循环点。 + // 2) 外部评分为 0 时只视为“未评分”,不清空手机端已有评分。 + val pcHasLoopPoints = data.start > 0 || data.end > 0 || hasMeaningfulCandidates(data.loopCandidatesJson) + val pcHasRating = data.rating > 0 + songUpdates.add( SongMetadataUpdate( songId = matchedSong.id, - start = data.start, - end = data.end, + start = if (pcHasLoopPoints) data.start else matchedSong.loopStart, + end = if (pcHasLoopPoints) data.end else matchedSong.loopEnd, total = if (data.total > 0) data.total else matchedSong.totalSamples, - rating = data.rating, + rating = if (pcHasRating) data.rating else matchedSong.rating, artistId = data.artist?.lowercase()?.let { finalArtistMap[it] @@ -208,7 +218,8 @@ object PcDatabaseImporter { }, displayName = data.displayName, coverPath = data.coverPath, - isAbPartB = matchedSong.isAbPartB // 同步时保持原有的 B 段标记喵 + isAbPartB = matchedSong.isAbPartB, // 同步时保持原有的 B 段标记喵 + loopCandidatesJson = if (pcHasLoopPoints) data.loopCandidatesJson else null ) ) syncCount++ @@ -323,9 +334,58 @@ object PcDatabaseImporter { val artist: String?, val album: String?, val rating: Int, - val coverPath: String? + val coverPath: String?, + val loopCandidatesJson: String? ) + private fun toMobileLoopCandidatesJson(json: String?): String? { + val source = json?.takeIf { it.isNotBlank() && it != "[]" && it != "{}" } ?: return json + + return try { + val input = JSONArray(source) + val output = JSONArray() + for (i in 0 until input.length()) { + val item = input.optJSONObject(i) ?: continue + val mobileItem = JSONObject().apply { + put("loopStart", readLong(item, "loopStart", "LoopStart")) + put("loopEnd", readLong(item, "loopEnd", "LoopEnd")) + put("score", readDouble(item, "score", "Score")) + put("noteDiff", readDouble(item, "noteDiff", "NoteDifference")) + if (hasAny(item, "loudnessDiff", "LoudnessDifference")) { + put("loudnessDiff", readDouble(item, "loudnessDiff", "LoudnessDifference")) + } + } + output.put(mobileItem) + } + output.toString() + } catch (_: Exception) { + // 解析失败时原样保存;未来若 PC 端 JSON 结构变化,至少不丢原始缓存喵。 + json + } + } + + private fun readLong(item: JSONObject, vararg keys: String): Long { + keys.forEach { key -> + if (item.has(key) && !item.isNull(key)) return item.optLong(key) + } + return 0L + } + + private fun readDouble(item: JSONObject, vararg keys: String): Double { + keys.forEach { key -> + if (item.has(key) && !item.isNull(key)) return item.optDouble(key) + } + return 0.0 + } + + private fun hasAny(item: JSONObject, vararg keys: String): Boolean { + return keys.any { key -> item.has(key) && !item.isNull(key) } + } + + private fun hasMeaningfulCandidates(json: String?): Boolean { + return !json.isNullOrBlank() && json != "[]" && json != "{}" + } + private fun getEffectiveDuration(song: Song, allLocalSongs: List): Long { if (song.isAbPartB) return song.duration diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/model/Song.kt b/app/src/main/java/com/cpu/seamlessloopmobile/model/Song.kt index 3373ac0..3443f37 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/model/Song.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/model/Song.kt @@ -162,5 +162,8 @@ data class SongMetadataUpdate( val albumId: Long?, val displayName: String?, val coverPath: String?, - val isAbPartB: Boolean = false + val isAbPartB: Boolean = false, + // PC 端把候选循环点存在 LoopPoints.LoopCandidatesJson;手机端存在 Songs.LoopCandidatesJson。 + // 默认为 null 表示“不更新缓存字段”,避免普通扫描批量更新时清空已有探测缓存喵。 + val loopCandidatesJson: String? = null ) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/model/SongDao.kt b/app/src/main/java/com/cpu/seamlessloopmobile/model/SongDao.kt index e1f0d77..5287bc7 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/model/SongDao.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/model/SongDao.kt @@ -133,6 +133,11 @@ interface SongDao { albumId = update.albumId, isAbPartB = update.isAbPartB ) + + // PC 端导入时会携带 LoopCandidatesJson;普通媒体扫描更新则保持 null,不碰已有缓存。 + if (update.loopCandidatesJson != null) { + updateLoopCandidatesJson(update.songId, update.loopCandidatesJson) + } } // 2. 关联表批量 Insert (分层优化,极大减少磁盘 IO 和 SQL 解析开销喵!) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainScreen.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainScreen.kt index 397f9c4..b1eb3ae 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainScreen.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainScreen.kt @@ -45,7 +45,8 @@ import androidx.compose.runtime.derivedStateOf fun MainScreen( viewModel: MainViewModel, playSong: (com.cpu.seamlessloopmobile.model.Song) -> Unit, - onSyncPc: () -> Unit + onSyncPc: () -> Unit, + onExportDatabase: () -> Unit ) { val uiState by viewModel.uiState.observeAsState(MusicUiState.Home) @@ -221,7 +222,8 @@ fun MainScreen( isVisible = isSettingsPanelVisible, onClose = remember(viewModel) { { viewModel.setSettingsPanelVisible(false) } }, onRescan = remember(viewModel) { { context -> viewModel.scanLibrary(context) } }, - onSyncPc = onSyncPc + onSyncPc = onSyncPc, + onExportDatabase = onExportDatabase ) } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsDrawer.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsDrawer.kt index 6e76c95..cb82b79 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsDrawer.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsDrawer.kt @@ -30,6 +30,7 @@ fun SettingsDrawer( onClose: () -> Unit, onRescan: (android.content.Context) -> Unit, onSyncPc: () -> Unit, + onExportDatabase: () -> Unit, modifier: Modifier = Modifier ) { AnimatedVisibility( @@ -72,7 +73,8 @@ fun SettingsDrawer( SettingsScreen( onClose = onClose, onRescan = { onRescan(context) }, - onSyncPc = onSyncPc + onSyncPc = onSyncPc, + onExportDatabase = onExportDatabase ) } } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt index b714fd7..6549d5a 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.unit.sp /** * 设置侧边栏面板喵!⚙️ - * 优雅美观,包含语言选择(壳子)、库重新扫描与 PC 数据库导入喵!(๑•̀ㅂ•́)و✧ + * 优雅美观,包含语言选择(壳子)、库重新扫描、PC 数据库导入与 PC 兼容数据库导出喵!(๑•̀ㅂ•́)و✧ */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -26,6 +26,7 @@ fun SettingsScreen( onClose: () -> Unit, onRescan: () -> Unit, onSyncPc: () -> Unit, + onExportDatabase: () -> Unit, modifier: Modifier = Modifier ) { var dropdownExpanded by remember { mutableStateOf(false) } @@ -224,11 +225,36 @@ fun SettingsScreen( Spacer(modifier = Modifier.width(8.dp)) Text("导入 PC 端数据库", fontWeight = FontWeight.Bold) } + + Spacer(modifier = Modifier.height(12.dp)) + + // PC 兼容数据库导出按钮 + // 注意:这里导出的不是手机 Room 原始库,而是转换后的 PC 端 3NF 数据库喵。 + // 用系统文件保存器选择导出位置,避免硬编码下载目录,也不用额外申请存储写权限。 + OutlinedButton( + onClick = { + onClose() + onExportDatabase() + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary + ), + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Icon( + imageVector = Icons.Default.CloudUpload, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("导出 PC 端数据库", fontWeight = FontWeight.Bold) + } Spacer(modifier = Modifier.height(12.dp)) Text( - text = "提示:重新扫描会自动发现设备上的所有音频文件喵!若有在电脑端编辑好的无缝循环数据,也可以直接选择 PC 数据库文件进行同步覆盖喵。(๑•̀ㅂ•́)و✧", + text = "提示:重新扫描会自动发现设备上的所有音频文件喵!若有在电脑端编辑好的无缝循环数据,可以直接选择 PC 数据库文件进行同步;手机端修改过的循环点、评分和歌单也可以导出为 PC 端可识别的数据库喵。(๑•̀ㅂ•́)و✧", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, lineHeight = 16.sp diff --git "a/docs/2026-06-06_PC\346\225\260\346\215\256\345\272\223\345\217\214\345\220\221\345\220\214\346\255\245\344\270\216\345\257\274\345\207\272.md" "b/docs/2026-06-06_PC\346\225\260\346\215\256\345\272\223\345\217\214\345\220\221\345\220\214\346\255\245\344\270\216\345\257\274\345\207\272.md" new file mode 100644 index 0000000..d92f51f --- /dev/null +++ "b/docs/2026-06-06_PC\346\225\260\346\215\256\345\272\223\345\217\214\345\220\221\345\220\214\346\255\245\344\270\216\345\257\274\345\207\272.md" @@ -0,0 +1,86 @@ +# 2026-06-06 PC 数据库双向同步与导出 + +## 背景 + +手机端此前只能导入 PC 端数据库,手机上修改过的循环点、评分、歌单等数据无法回传给 PC 端使用。本次新增“导出 PC 端数据库”能力,让手机端修改可以转换为 PC 端可识别的 3NF SQLite 数据库。 + +## 用户入口 + +设置抽屉 → **数据同步与管理**: + +- `导入 PC 端数据库` +- `导出 PC 端数据库` + +导出使用 Android SAF 文件保存器,不需要额外写存储权限。默认文件名: + +```text +seamless_loop_pc_export_yyyyMMdd_HHmmss.db +``` + +## 导出格式 + +导出器不是复制手机 Room 数据库,而是创建临时 SQLite,并生成 PC 端 3NF schema: + +- `Tracks` +- `Artists` +- `Albums` +- `LoopPoints` +- `UserRatings` +- `Playlists` +- `PlaylistItems` +- `MusicFolders` +- `AppSettings` +- `QueuedTracks` + +核心映射: + +| 手机端 | PC 端 | +|---|---| +| `Songs` | `Tracks` | +| `LoopPoints.SongId` | `LoopPoints.TrackId` | +| `UserRatings.SongId` | `UserRatings.TrackId` | +| `PlayQueue` | `QueuedTracks` | +| `Songs.LoopCandidatesJson` | `LoopPoints.LoopCandidatesJson` | + +导出时会重新分配 PC 端 `Tracks.Id`,并用 `mobileSongIdToPcTrackId` 维护歌单、循环点、评分、队列的外键映射。 + +## 候选循环点 JSON 转换 + +手机端 JNI 返回数据类使用 camelCase: + +```json +[{"loopStart":1,"loopEnd":2,"score":0.98,"noteDiff":0.1}] +``` + +PC 端样例库使用 PascalCase: + +```json +[{"LoopStart":1,"LoopEnd":2,"Score":0.98,"NoteDifference":0.1}] +``` + +本次实现: + +- 导出:camelCase → PascalCase +- 导入:PascalCase → camelCase +- 解析失败时保留原始 JSON,避免丢失缓存 + +## 导入保护策略 + +对齐 PC 端 `DatabaseHelper.SyncWithExternalDatabase` 的保护逻辑: + +- PC 端没有实质循环点成果时,不用 `0/0` 覆盖手机端已有循环点 +- PC 端评分为 `0` 时视为未评分,不清空手机端已有评分 +- `LoopCandidatesJson` 只有在 PC 端有实质候选点时才更新,否则保持手机端缓存不变 + +## 涉及文件 + +- `app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseExporter.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/db/PcDatabaseImporter.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/model/Song.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/model/SongDao.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/MainActivity.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt` + +## 验证 + +用户已在设备上完成测试,导出功能运行正常。实现时额外对照了 `temp/` 中的 PC 端数据库逻辑与 `LoopData.db` 样例,确认 schema、列名与 JSON 键名兼容。 From 08d7e84247ad189b6c6e45c9d657a44dd9c577d8 Mon Sep 17 00:00:00 2001 From: daititii <102270042+daititii@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:50:00 +0800 Subject: [PATCH 3/4] Remove GitHub repository link Remove remote repository link from AGENTS.md --- AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a7c5649..bcf0600 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,6 @@ Android 无缝循环音频播放器,Kotlin + C++ (Oboe) 混合架构。 单模块项目 (`:app`),Min SDK 26,Compile/Target SDK 35。 Kotlin 2.1.0 + AGP 9.0.1 + Gradle 9.1.0,Compose compiler 由 Kotlin 2.1.0 内置。 -远端仓库:`https://github.com/daititii/SeamlessLoopMobileNative.git`(GitHub MCP 可用时优先用 MCP 查询 PR/Issue/远端文件状态)。 ## 构建与测试 From 0be05539bc28ea631bdbf8c82423702e0cf4dacf Mon Sep 17 00:00:00 2001 From: daititii <102270042+daititii@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:50:33 +0800 Subject: [PATCH 4/4] Delete .claude directory --- .claude/settings.local.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 75d21ba..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "PowerShell(.\\\\gradlew.bat :app:generateDebugBuildConfig 2>&1 | Select-Object -Last 5)" - ] - } -}