diff --git a/src/Core/AudioFileMetaValidator.cs b/src/Core/AudioFileMetaValidator.cs
new file mode 100644
index 0000000..45f5c44
--- /dev/null
+++ b/src/Core/AudioFileMetaValidator.cs
@@ -0,0 +1,127 @@
+namespace OCES.Resonance.Core;
+
+///
+/// 音频元数据验证器,验证元数据完整性和格式
+///
+public class AudioFileMetaValidator
+{
+ ///
+ /// 验证必填字段是否完整
+ ///
+ /// 音频元数据对象
+ /// 验证错误列表
+ /// 是否验证通过
+ public bool Validate(AudioFileMeta meta, out List errors)
+ {
+ errors = new List();
+
+ if (meta == null)
+ {
+ errors.Add("元数据对象为空");
+ return false;
+ }
+
+ // 验证必备字符串字段
+ ValidateRequiredField(meta.UniqueId, "UniqueId", errors);
+ ValidateRequiredField(meta.Md5, "Md5", errors);
+ ValidateRequiredField(meta.Path, "Path", errors);
+ ValidateRequiredField(meta.Filename, "Filename", errors);
+ ValidateRequiredField(meta.Folder, "Folder", errors);
+ ValidateRequiredField(meta.Directory, "Directory", errors);
+ ValidateRequiredField(meta.Type, "Type", errors);
+
+ // 验证数值字段
+ ValidatePositiveValue(meta.Duration, "Duration", errors);
+ ValidatePositiveValue(meta.TotalSamples, "TotalSamples", errors);
+ ValidatePositiveValue(meta.BitDepth, "BitDepth", errors);
+ ValidatePositiveValue(meta.Channels, "Channels", errors);
+ ValidatePositiveValue(meta.SampleRate, "SampleRate", errors);
+
+ // 验证日期字段
+ ValidateRequiredField(meta.DateAdded, "DateAdded", errors);
+ ValidateRequiredField(meta.OriginalModificationDate, "OriginalModificationDate", errors);
+ ValidateRequiredField(meta.OriginationTime, "OriginationTime", errors);
+
+ // 验证文件路径是否存在
+ if (!File.Exists(meta.Path))
+ {
+ errors.Add($"文件不存在:{meta.Path}");
+ }
+
+ // 验证 MD5 格式(32 位十六进制)
+ if (!IsValidMd5(meta.Md5))
+ {
+ errors.Add($"MD5 格式无效:{meta.Md5}");
+ }
+
+ return errors.Count == 0;
+ }
+
+ ///
+ /// 验证必填字符串字段
+ ///
+ private static void ValidateRequiredField(string? value, string fieldName, List errors)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ errors.Add($"{fieldName} 不能为空");
+ }
+ }
+
+ ///
+ /// 验证必填值类型字段
+ ///
+ private static void ValidateRequiredField(T value, string fieldName, List errors) where T : struct
+ {
+ if (EqualityComparer.Default.Equals(value, default))
+ {
+ errors.Add($"{fieldName} 不能为空");
+ }
+ }
+
+ ///
+ /// 验证正数值
+ ///
+ private static void ValidatePositiveValue(double value, string fieldName, List errors)
+ {
+ if (value <= 0)
+ {
+ errors.Add($"{fieldName} 必须为正数 (当前值:{value})");
+ }
+ }
+
+ ///
+ /// 验证正数整数值
+ ///
+ private static void ValidatePositiveValue(int value, string fieldName, List errors)
+ {
+ if (value <= 0)
+ {
+ errors.Add($"{fieldName} 必须为正数 (当前值:{value})");
+ }
+ }
+
+ ///
+ /// 验证正数无符号整数值
+ ///
+ private static void ValidatePositiveValue(uint value, string fieldName, List errors)
+ {
+ if (value <= 0)
+ {
+ errors.Add($"{fieldName} 必须为正数 (当前值:{value})");
+ }
+ }
+
+ ///
+ /// 验证 MD5 格式是否有效
+ ///
+ private static bool IsValidMd5(string md5)
+ {
+ if (string.IsNullOrWhiteSpace(md5) || md5.Length != 32)
+ {
+ return false;
+ }
+
+ return md5.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'));
+ }
+}
diff --git a/src/Core/AudioFileScanner.cs b/src/Core/AudioFileScanner.cs
new file mode 100644
index 0000000..4982f31
--- /dev/null
+++ b/src/Core/AudioFileScanner.cs
@@ -0,0 +1,49 @@
+namespace OCES.Resonance.Core;
+
+///
+/// 音频文件扫描器,负责递归扫描目录并识别音频文件
+///
+public class AudioFileScanner
+{
+ private static readonly string[] SupportedExtensions =
+ {
+ ".wav", ".mp3", ".flac", ".aiff", ".aif", ".m4a", ".ogg", ".wma", ".bwf", ".wav64"
+ };
+
+ ///
+ /// 扫描指定目录,返回所有音频文件路径
+ ///
+ /// 要扫描的目录路径
+ /// 是否递归扫描子目录
+ /// 音频文件路径集合
+ public IEnumerable ScanDirectory(string directoryPath, bool recursive = true)
+ {
+ if (!Directory.Exists(directoryPath))
+ {
+ throw new DirectoryNotFoundException($"目录不存在:{directoryPath}");
+ }
+
+ var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
+
+ return SupportedExtensions
+ .SelectMany(ext => Directory.EnumerateFiles(directoryPath, $"*{ext}", searchOption))
+ .Where(IsSupportedAudioFile)
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// 判断文件是否为支持的音频格式
+ ///
+ /// 文件路径
+ /// 是否为支持的音频格式
+ public bool IsSupportedAudioFile(string filePath)
+ {
+ if (string.IsNullOrWhiteSpace(filePath))
+ {
+ return false;
+ }
+
+ var extension = Path.GetExtension(filePath).ToLowerInvariant();
+ return SupportedExtensions.Contains(extension);
+ }
+}
diff --git a/src/Core/AudioLibraryService.cs b/src/Core/AudioLibraryService.cs
new file mode 100644
index 0000000..f9144a0
--- /dev/null
+++ b/src/Core/AudioLibraryService.cs
@@ -0,0 +1,135 @@
+namespace OCES.Resonance.Core;
+
+///
+/// 音频库管理服务,协调扫描、读取和入库的完整流程
+///
+public class AudioLibraryService
+{
+ private readonly AudioFileScanner _scanner;
+ private readonly AudioMetadataReader _metadataReader;
+
+ public AudioLibraryService()
+ {
+ _scanner = new AudioFileScanner();
+ _metadataReader = new AudioMetadataReader();
+ }
+
+ ///
+ /// 扫描目录并将结果添加到数据库
+ ///
+ /// 要扫描的目录路径
+ /// 数据库名称(不含扩展名)
+ /// 是否跳过已存在的文件
+ /// 扫描结果统计
+ public async Task ScanAndImportToLibrary(
+ string directoryPath,
+ string databaseName = "default",
+ bool skipExisting = true)
+ {
+ var result = new ScanResult
+ {
+ TotalFiles = 0,
+ SuccessCount = 0,
+ SkipCount = 0,
+ ErrorCount = 0,
+ Errors = new List()
+ };
+
+ // 初始化数据库
+ Database.InitializeDatabase();
+
+ // 扫描所有音频文件
+ var audioFiles = _scanner.ScanDirectory(directoryPath).ToList();
+ result.TotalFiles = audioFiles.Count;
+
+ if (audioFiles.Count == 0)
+ {
+ return result;
+ }
+
+ var metadataList = new List();
+
+ // 读取每个文件的元数据
+ foreach (var filePath in audioFiles)
+ {
+ try
+ {
+ // 计算 MD5 用于查重
+ var md5 = CalculateMd5ForFile(filePath);
+
+ // 检查是否已存在
+ if (skipExisting && Database.EntryExists(md5, filePath))
+ {
+ result.SkipCount++;
+ continue;
+ }
+
+ // 读取元数据
+ var metadata = _metadataReader.ReadMetadata(filePath);
+ metadataList.Add(metadata);
+ result.SuccessCount++;
+ }
+ catch (Exception ex)
+ {
+ result.ErrorCount++;
+ result.Errors.Add($"读取失败 {filePath}: {ex.Message}");
+ }
+ }
+
+ // 批量入库
+ if (metadataList.Count > 0)
+ {
+ try
+ {
+ Database.AddEntries(metadataList);
+ }
+ catch (Exception ex)
+ {
+ result.Errors.Add($"批量入库失败:{ex.Message}");
+ throw;
+ }
+ }
+
+ return await Task.FromResult(result);
+ }
+
+ ///
+ /// 计算文件 MD5(用于查重,不创建完整 AudioFileMeta 对象)
+ ///
+ private static string CalculateMd5ForFile(string filePath)
+ {
+ using var md5 = System.Security.Cryptography.MD5.Create();
+ using var stream = File.OpenRead(filePath);
+ var hash = md5.ComputeHash(stream);
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+}
+
+///
+/// 扫描结果统计
+///
+public class ScanResult
+{
+ /// 扫描到的文件总数
+ public int TotalFiles { get; set; }
+
+ /// 成功入库的文件数
+ public int SuccessCount { get; set; }
+
+ /// 跳过的文件数(已存在)
+ public int SkipCount { get; set; }
+
+ /// 读取失败的文件数
+ public int ErrorCount { get; set; }
+
+ /// 错误详情列表
+ public List Errors { get; set; } = new();
+
+ ///
+ /// 获取人类可读的统计报告
+ ///
+ public string GetSummary()
+ {
+ return $"扫描完成:共 {TotalFiles} 个文件,成功 {SuccessCount} 个,跳过 {SkipCount} 个,失败 {ErrorCount} 个";
+ }
+}
diff --git a/src/Core/AudioMetadataReader.cs b/src/Core/AudioMetadataReader.cs
new file mode 100644
index 0000000..fcdb4c8
--- /dev/null
+++ b/src/Core/AudioMetadataReader.cs
@@ -0,0 +1,173 @@
+using System.Security.Cryptography;
+using ATL;
+
+namespace OCES.Resonance.Core;
+
+///
+/// 音频元数据读取器,使用 ATL 库读取音频文件的技术参数和元数据
+///
+public class AudioMetadataReader
+{
+ ///
+ /// 从音频文件读取元数据
+ ///
+ /// 音频文件路径
+ /// 音频元数据对象
+ public AudioFileMeta ReadMetadata(string filePath)
+ {
+ if (!File.Exists(filePath))
+ {
+ throw new FileNotFoundException($"文件不存在:{filePath}");
+ }
+
+ var track = new Track(filePath);
+ var fileInfo = new FileInfo(filePath);
+
+ // 获取音频技术参数
+ var durationSeconds = track.DurationMs / 1000.0;
+ var sampleRate = track.SampleRate;
+ var channels = track.ChannelsArrangement?.NbChannels ?? 2;
+ var bitDepth = GetBitDepth(track);
+ var totalSamples = (uint)(durationSeconds * sampleRate);
+
+ // 提取路径信息
+ var fileName = Path.GetFileName(filePath);
+ var folder = Path.GetDirectoryName(filePath) ?? string.Empty;
+ var directory = Path.GetDirectoryName(filePath) ?? string.Empty;
+ var folderName = new DirectoryInfo(folder).Name;
+
+ // 生成唯一标识
+ var md5 = CalculateMd5(filePath);
+ var uniqueId = GenerateUuid();
+
+ // 获取文件修改时间
+ var lastWriteTime = fileInfo.LastWriteTime;
+ var createTime = fileInfo.CreationTime;
+
+ // 获取文件扩展名作为类型
+ var fileType = Path.GetExtension(filePath).TrimStart('.').ToUpperInvariant();
+
+ // 映射 ATL 元数据到 AudioFileMeta
+ var meta = new AudioFileMeta
+ {
+ // 必备条目
+ Id = 0, // 自增 ID,由数据库生成
+ UniqueId = uniqueId,
+ Md5 = md5,
+ Path = Path.GetFullPath(filePath),
+ Filename = fileName,
+ Folder = folderName,
+ Directory = directory,
+ Duration = durationSeconds,
+ TotalSamples = totalSamples,
+ BitDepth = bitDepth,
+ Channels = channels,
+ SampleRate = (int)sampleRate,
+ Type = fileType,
+ DateAdded = DateTime.Now,
+ OriginalModificationDate = lastWriteTime,
+ OriginationTime = createTime,
+
+ // 可选条目
+ Bpm = track.BPM > 0 ? track.BPM : null,
+ Description = track.Description,
+ Genre = track.Genre,
+ Artist = track.Artist,
+ Composer = track.Composer,
+ Publisher = track.Publisher,
+ Copyright = track.Copyright,
+
+ // BWF 特定字段 (通过附加数据获取)
+ CodingHistory = GetBwfField(track, "CodingHistory"),
+ Originator = GetBwfField(track, "Originator"),
+ OriginatorRef = GetBwfField(track, "OriginatorRef"),
+ };
+
+ return meta;
+ }
+
+ ///
+ /// 获取 BWF 字段值
+ ///
+ private static string? GetBwfField(Track track, string fieldName)
+ {
+ try
+ {
+ if (track.AdditionalFields != null && track.AdditionalFields.TryGetValue(fieldName, out string? field))
+ {
+ return field;
+ }
+ }
+ catch
+ {
+ // 忽略 BWF 字段读取错误
+ }
+ return null;
+ }
+
+ ///
+ /// 获取自定义字段值
+ ///
+ private static string? GetCustomField(Track track, int index)
+ {
+ try
+ {
+ string fieldName = $"User{index}";
+ if (track.AdditionalFields != null && track.AdditionalFields.TryGetValue(fieldName, out string? field))
+ {
+ return field;
+ }
+ }
+ catch
+ {
+ // 忽略自定义字段读取错误
+ }
+ return null;
+ }
+
+ ///
+ /// 获取音频位深度
+ ///
+ private static int GetBitDepth(Track track)
+ {
+ // ATL 可能通过不同方式提供位深信息
+ if (track.BitDepth > 0)
+ {
+ return track.BitDepth;
+ }
+
+ // 根据格式推断默认位深
+ var extension = Path.GetExtension(track.Path).ToLowerInvariant();
+ return extension switch
+ {
+ ".wav" or ".bwf" or ".wav64" => 24, // 现代 WAV 通常 24bit
+ ".flac" => 24,
+ ".aiff" or ".aif" => 16,
+ ".mp3" => 16, // MP3 实际没有位深概念,默认 16
+ _ => 16
+ };
+ }
+
+ ///
+ /// 计算文件 MD5 值
+ ///
+ /// 文件路径
+ /// MD5 哈希值(十六进制字符串)
+ private static string CalculateMd5(string filePath)
+ {
+ using var md5 = MD5.Create();
+ using var stream = File.OpenRead(filePath);
+
+ var hash = md5.ComputeHash(stream);
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+
+ ///
+ /// 生成 UUID
+ ///
+ /// UUID 字符串
+ private static string GenerateUuid()
+ {
+ return Guid.NewGuid().ToString("N"); // 无连字符的 UUID
+ }
+}
diff --git a/src/Core/Database.cs b/src/Core/Database.cs
index e53d385..461620b 100644
--- a/src/Core/Database.cs
+++ b/src/Core/Database.cs
@@ -1,9 +1,239 @@
+using System.Data;
+using System.Data.SQLite;
+using Dapper;
+
namespace OCES.Resonance.Core;
public static class Database
{
- internal static bool AddEntry()
+ static readonly string DefaultConnectionString = "Data Source=default.db;Version=3;";
+
+ ///
+ /// 获取数据库连接
+ ///
+ /// 数据库名称(不含扩展名)
+ /// 数据库连接
+ public static IDbConnection GetConnection(string dbName = "default")
{
- return false;
+ var connectionString = $"Data Source={dbName}.db;Version=3;";
+ return new SQLiteConnection(connectionString);
+ }
+
+ ///
+ /// 初始化数据库表结构
+ ///
+ public static void InitializeDatabase()
+ {
+ using var connection = GetConnection();
+ connection.Open();
+
+ const string sql = @"
+ CREATE TABLE IF NOT EXISTS audio_files (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unique_id TEXT NOT NULL UNIQUE,
+ short_id TEXT,
+ md5 TEXT NOT NULL,
+ path TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ folder TEXT NOT NULL,
+ directory TEXT NOT NULL,
+ duration REAL NOT NULL,
+ total_samples INTEGER NOT NULL,
+ bit_depth INTEGER NOT NULL,
+ channels INTEGER NOT NULL,
+ sample_rate INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ date_added TEXT NOT NULL,
+ original_modification_date TEXT NOT NULL,
+ origination_time TEXT NOT NULL,
+
+ bpm REAL,
+ frame_rate TEXT,
+ timecode INTEGER,
+ description TEXT,
+ category TEXT,
+ subcategory TEXT,
+ cat_id TEXT,
+ category_full TEXT,
+ genre TEXT,
+ style TEXT,
+ mood TEXT,
+ keywords TEXT,
+ rating INTEGER,
+ artist TEXT,
+ composer TEXT,
+ designer TEXT,
+ recordist TEXT,
+ publisher TEXT,
+ manufacturer TEXT,
+ originator TEXT,
+ originator_ref TEXT,
+ project_name TEXT,
+ library TEXT,
+ cd_title TEXT,
+ track_title TEXT,
+ episode TEXT,
+ scene TEXT,
+ take TEXT,
+ tape TEXT,
+ cue_number INTEGER,
+ sync_point INTEGER,
+ release_date TEXT,
+ track_year TEXT,
+ is_edited INTEGER,
+ is_split INTEGER,
+ location TEXT,
+ group TEXT,
+ markers TEXT,
+ comments TEXT,
+ notes TEXT,
+ copyright TEXT,
+ coding_history TEXT,
+ microphone TEXT,
+ mic_perspective TEXT,
+ user1 TEXT,
+ user2 TEXT,
+ user3 TEXT,
+ user4 TEXT,
+ user5 TEXT,
+ user6 TEXT,
+ user7 TEXT,
+ user8 TEXT,
+
+ INDEX idx_md5 (md5),
+ INDEX idx_path (path),
+ INDEX idx_unique_id (unique_id)
+ );
+ ";
+
+ connection.Execute(sql);
+ }
+
+ ///
+ /// 添加单条音频记录
+ ///
+ /// 音频元数据
+ /// 是否添加成功
+ public static bool AddEntry(AudioFileMeta meta)
+ {
+ try
+ {
+ using var connection = GetConnection();
+ connection.Open();
+
+ const string sql = @"
+ INSERT INTO audio_files (
+ unique_id, short_id, md5, path, filename, folder, directory,
+ duration, total_samples, bit_depth, channels, sample_rate, type,
+ date_added, original_modification_date, origination_time,
+ bpm, frame_rate, timecode, description, category, subcategory,
+ cat_id, category_full, genre, style, mood, keywords, rating,
+ artist, composer, designer, recordist, publisher, manufacturer,
+ originator, originator_ref, project_name, library, cd_title,
+ track_title, episode, scene, take, tape, cue_number, sync_point,
+ release_date, track_year, is_edited, is_split, location, group,
+ markers, comments, notes, copyright, coding_history, microphone,
+ mic_perspective, user1, user2, user3, user4, user5, user6, user7, user8
+ ) VALUES (
+ @UniqueId, @ShortId, @Md5, @Path, @Filename, @Folder, @Directory,
+ @Duration, @TotalSamples, @BitDepth, @Channels, @SampleRate, @Type,
+ @DateAdded, @OriginalModificationDate, @OriginationTime,
+ @Bpm, @FrameRate, @Timecode, @Description, @Category, @Subcategory,
+ @CatId, @CategoryFull, @Genre, @Style, @Mood, @Keywords, @Rating,
+ @Artist, @Composer, @Designer, @Recordist, @Publisher, @Manufacturer,
+ @Originator, @OriginatorRef, @ProjectName, @Library, @CdTitle,
+ @TrackTitle, @Episode, @Scene, @Take, @Tape, @CueNumber, @SyncPoint,
+ @ReleaseDate, @TrackYear, @IsEdited, @IsSplit, @Location, @Group,
+ @Markers, @Comments, @Notes, @Copyright, @CodingHistory, @Microphone,
+ @MicPerspective, @User1, @User2, @User3, @User4, @User5, @User6, @User7, @User8
+ );
+ ";
+
+ connection.Execute(sql, meta);
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// 批量添加音频记录
+ ///
+ /// 音频元数据列表
+ /// 成功添加的记录数
+ public static int AddEntries(IEnumerable entries)
+ {
+ var count = 0;
+ using var connection = GetConnection();
+ connection.Open();
+
+ using var transaction = connection.BeginTransaction();
+ try
+ {
+ const string sql = @"
+ INSERT INTO audio_files (
+ unique_id, short_id, md5, path, filename, folder, directory,
+ duration, total_samples, bit_depth, channels, sample_rate, type,
+ date_added, original_modification_date, origination_time,
+ bpm, frame_rate, timecode, description, category, subcategory,
+ cat_id, category_full, genre, style, mood, keywords, rating,
+ artist, composer, designer, recordist, publisher, manufacturer,
+ originator, originator_ref, project_name, library, cd_title,
+ track_title, episode, scene, take, tape, cue_number, sync_point,
+ release_date, track_year, is_edited, is_split, location, group,
+ markers, comments, notes, copyright, coding_history, microphone,
+ mic_perspective, user1, user2, user3, user4, user5, user6, user7, user8
+ ) VALUES (
+ @UniqueId, @ShortId, @Md5, @Path, @Filename, @Folder, @Directory,
+ @Duration, @TotalSamples, @BitDepth, @Channels, @SampleRate, @Type,
+ @DateAdded, @OriginalModificationDate, @OriginationTime,
+ @Bpm, @FrameRate, @Timecode, @Description, @Category, @Subcategory,
+ @CatId, @CategoryFull, @Genre, @Style, @Mood, @Keywords, @Rating,
+ @Artist, @Composer, @Designer, @Recordist, @Publisher, @Manufacturer,
+ @Originator, @OriginatorRef, @ProjectName, @Library, @CdTitle,
+ @TrackTitle, @Episode, @Scene, @Take, @Tape, @CueNumber, @SyncPoint,
+ @ReleaseDate, @TrackYear, @IsEdited, @IsSplit, @Location, @Group,
+ @Markers, @Comments, @Notes, @Copyright, @CodingHistory, @Microphone,
+ @MicPerspective, @User1, @User2, @User3, @User4, @User5, @User6, @User7, @User8
+ );
+ ";
+
+ foreach (var entry in entries)
+ {
+ connection.Execute(sql, entry, transaction);
+ count++;
+ }
+
+ transaction.Commit();
+ }
+ catch (Exception)
+ {
+ transaction.Rollback();
+ throw;
+ }
+
+ return count;
+ }
+
+ ///
+ /// 检查记录是否已存在
+ ///
+ /// 文件 MD5 值
+ /// 文件路径
+ /// 是否存在
+ public static bool EntryExists(string md5, string filePath)
+ {
+ using var connection = GetConnection();
+ connection.Open();
+
+ const string sql = @"
+ SELECT COUNT(*) FROM audio_files
+ WHERE md5 = @Md5 OR path = @Path;
+ ";
+
+ var count = connection.ExecuteScalar(sql, new { Md5 = md5, Path = filePath });
+ return count > 0;
}
}