Skip to content

Commit 51c7cc4

Browse files
committed
feat: 试听预览区间编辑
预览区间存储于 ACB TrackEventTable 的 ADX2 序列命令(逆向确认与 maimai 同构)。 写入采用 template_preview.acb(CriTable 重存官方 ACB 所得,round-trip 稳定)整体重建: AcbName/CueLength/预览命令/AWB MD5(CHUNITHM 用 MD5 非 maimai 的 SHA1)/采样率/样本数/AFS2 头。 重建产物经逐字段对比官方 ground truth 验证一致。 前端 wavesurfer.js 波形选区编辑器;CLI 新增 preview 命令
1 parent 2dd6fd9 commit 51c7cc4

14 files changed

Lines changed: 512 additions & 2 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System.ComponentModel;
2+
using System.Text.RegularExpressions;
3+
using ChuChartManager;
4+
using Spectre.Console;
5+
using Spectre.Console.Cli;
6+
7+
namespace ChuChartManager.CLI.Commands;
8+
9+
public partial class PreviewCommand : Command<PreviewCommand.Settings>
10+
{
11+
[GeneratedRegex(@"(\d+)")]
12+
private static partial Regex MusicIdRegex();
13+
14+
public class Settings : CommandSettings
15+
{
16+
[CommandArgument(0, "<acb>")]
17+
[Description("ACB 文件路径")]
18+
public string Acb { get; set; } = "";
19+
20+
[CommandOption("-s|--start <MS>")]
21+
[Description("预览起点(毫秒),与 --end 一起使用进入写入模式")]
22+
public uint? Start { get; set; }
23+
24+
[CommandOption("-e|--end <MS>")]
25+
[Description("预览终点(毫秒)")]
26+
public uint? End { get; set; }
27+
28+
[CommandOption("--awb <PATH>")]
29+
[Description("AWB 文件路径(默认取 ACB 同目录同名 .awb)")]
30+
public string? Awb { get; set; }
31+
32+
[CommandOption("-i|--id <ID>")]
33+
[Description("曲目 ID(默认从文件名解析)")]
34+
public int? Id { get; set; }
35+
36+
public override ValidationResult Validate()
37+
{
38+
if (!File.Exists(Acb))
39+
return ValidationResult.Error($"ACB 文件不存在: {Acb}");
40+
if (Start.HasValue != End.HasValue)
41+
return ValidationResult.Error("--start 和 --end 必须同时指定");
42+
if (Start.HasValue && Start.Value >= End!.Value)
43+
return ValidationResult.Error("起点必须小于终点");
44+
return ValidationResult.Success();
45+
}
46+
}
47+
48+
public override int Execute(CommandContext context, Settings settings)
49+
{
50+
if (!settings.Start.HasValue)
51+
{
52+
var preview = AcbPreviewHelper.Read(settings.Acb);
53+
if (preview == null)
54+
{
55+
AnsiConsole.MarkupLine("[yellow]该 ACB 中没有预览命令标记[/]");
56+
return 1;
57+
}
58+
59+
AnsiConsole.MarkupLine($"预览区间: [green]{preview.StartMs}ms ~ {preview.EndMs}ms[/] (时长 {preview.EndMs - preview.StartMs}ms)");
60+
return 0;
61+
}
62+
63+
var awbPath = settings.Awb ?? Path.ChangeExtension(settings.Acb, ".awb");
64+
if (!File.Exists(awbPath))
65+
{
66+
AnsiConsole.MarkupLine($"[red]AWB 文件不存在: {Markup.Escape(awbPath)}[/]");
67+
return 1;
68+
}
69+
70+
var id = settings.Id ?? ParseIdFromFileName(settings.Acb);
71+
72+
AcbPreviewHelper.Write(settings.Acb, awbPath, id, settings.Start.Value, settings.End!.Value);
73+
74+
var written = AcbPreviewHelper.Read(settings.Acb);
75+
if (written == null)
76+
{
77+
AnsiConsole.MarkupLine("[red]写入后回读失败[/]");
78+
return 1;
79+
}
80+
81+
AnsiConsole.MarkupLine($"[green]✓[/] 已写入预览区间 {written.StartMs}ms ~ {written.EndMs}ms");
82+
return 0;
83+
}
84+
85+
private static int ParseIdFromFileName(string path)
86+
{
87+
var match = MusicIdRegex().Match(Path.GetFileNameWithoutExtension(path));
88+
return match.Success ? int.Parse(match.Groups[1].Value) : 0;
89+
}
90+
}

ChuChartManager.CLI/Program.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@
3838
.WithDescription("检查数据完整性(缺音频、缺封面、XML 损坏)")
3939
.WithExample("validate", "-p", "G:\\");
4040

41+
config.AddCommand<PreviewCommand>("preview")
42+
.WithDescription("查看或写入 ACB 试听预览区间")
43+
.WithExample("preview", "music0820.acb")
44+
.WithExample("preview", "music0820.acb", "-s", "5000", "-e", "20000");
45+
4146
config.AddCommand<DebugCommand>("debug")
4247
.WithDescription("以控制台模式启动主程序,用于查看日志输出");
4348
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System.Buffers.Binary;
2+
using System.Security.Cryptography;
3+
using SonicAudioLib.CriMw;
4+
using VGAudio.Containers.Wave;
5+
using VGAudio.Formats.Pcm16;
6+
7+
namespace ChuChartManager;
8+
9+
public static class AcbPreviewHelper
10+
{
11+
public record PreviewTime(double StartMs, double EndMs);
12+
13+
// CRIWARE ADX2 序列命令的固定中段(逆向):其前 4 字节为 loopStart(ms 大端),
14+
// 本标记后 4 字节为 loopEnd,再之后为结束符 0x0FA0;标记前导为 0x03 0xE7 0x04
15+
private static readonly byte[] Marker = [0x07, 0xD0, 0x04, 0x00, 0x02, 0x00, 0x01, 0x07, 0xD1, 0x04];
16+
17+
public static PreviewTime? Read(string acbPath)
18+
{
19+
if (!File.Exists(acbPath)) return null;
20+
21+
var bytes = File.ReadAllBytes(acbPath);
22+
var m = Locate(bytes);
23+
if (m < 0) return null;
24+
25+
var start = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(m - 4, 4));
26+
var end = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(m + 10, 4));
27+
return new PreviewTime(start, end);
28+
}
29+
30+
/// <summary>从 template_preview.acb 整体重建 ACB(对齐 MCM CreateAcbWithPreview),AWB 不动</summary>
31+
public static void Write(string acbPath, string awbPath, int musicId, uint startMs, uint endMs)
32+
{
33+
var awbBytes = File.ReadAllBytes(awbPath);
34+
var hca = AudioHelper.ExtractHcaFromAwb(awbPath) ?? throw new InvalidDataException("AWB 中未找到 HCA");
35+
var wav = AudioHelper.DecodeHcaToWav(hca) ?? throw new InvalidDataException("HCA 解码失败");
36+
37+
var format = new WaveReader().Read(wav).GetFormat<Pcm16Format>();
38+
var durationMs = (uint)(format.SampleCount * 1000.0 / format.SampleRate);
39+
if (endMs > durationMs) endMs = durationMs;
40+
if (startMs >= endMs) throw new ArgumentException("预览起点必须小于终点");
41+
42+
var templatePath = Path.Combine(StaticSettings.ExeDir, "Resources", "template_preview.acb");
43+
var acb = LoadTable(File.ReadAllBytes(templatePath));
44+
45+
acb.Rows[0]["Name"] = $"music{musicId:D4}";
46+
47+
UpdateSubTable(acb, "CueTable", t => t.Rows[0]["Length"] = durationMs);
48+
UpdateSubTable(acb, "TrackEventTable", t => t.Rows[1]["Command"] = BuildCommand(startMs, endMs));
49+
UpdateSubTable(acb, "StreamAwbHash", t =>
50+
{
51+
t.Rows[0]["Name"] = $"music{musicId:D4}";
52+
t.Rows[0]["Hash"] = MD5.HashData(awbBytes);
53+
});
54+
UpdateSubTable(acb, "WaveformTable", t =>
55+
{
56+
t.Rows[0]["SamplingRate"] = (ushort)format.SampleRate;
57+
t.Rows[0]["NumSamples"] = (uint)format.SampleCount;
58+
});
59+
UpdateSubTable(acb, "StreamAwbAfs2Header", t => t.Rows[0]["Header"] = ExtractAfs2Header(awbBytes));
60+
61+
acb.WriterSettings = CriTableWriterSettings.Adx2Settings;
62+
using var fs = File.Create(acbPath);
63+
acb.Write(fs);
64+
}
65+
66+
private static CriTable LoadTable(byte[] bytes)
67+
{
68+
var table = new CriTable();
69+
table.Read(new MemoryStream(bytes));
70+
return table;
71+
}
72+
73+
private static void UpdateSubTable(CriTable acb, string name, Action<CriTable> mutate)
74+
{
75+
if (acb.Rows[0][name] is not byte[] subBytes || subBytes.Length == 0)
76+
throw new InvalidDataException($"模板缺少 {name}");
77+
78+
var sub = LoadTable(subBytes);
79+
mutate(sub);
80+
using var ms = new MemoryStream();
81+
sub.Write(ms);
82+
acb.Rows[0][name] = ms.ToArray();
83+
}
84+
85+
// AFS2 头长度公式(与 MCM 相同,已对 CHUNITHM AWB 实测验证):
86+
// 16 字节固定头 + (id宽+offset宽+length宽) × (文件数+1)
87+
private static byte[] ExtractAfs2Header(byte[] awbBytes)
88+
{
89+
var count = BitConverter.ToInt32(awbBytes, 8) + 1;
90+
var headSize = 16 + awbBytes[5] * count + awbBytes[6] * count + awbBytes[7] * count;
91+
return awbBytes[..headSize];
92+
}
93+
94+
private static byte[] BuildCommand(uint startMs, uint endMs)
95+
{
96+
var cmd = new byte[27];
97+
cmd[0] = 0x03; cmd[1] = 0xE7; cmd[2] = 0x04;
98+
BinaryPrimitives.WriteUInt32BigEndian(cmd.AsSpan(3, 4), startMs);
99+
Marker.CopyTo(cmd.AsSpan(7));
100+
BinaryPrimitives.WriteUInt32BigEndian(cmd.AsSpan(17, 4), endMs);
101+
ReadOnlySpan<byte> tail = [0x0F, 0xA0, 0x00, 0x00, 0x00, 0x00];
102+
tail.CopyTo(cmd.AsSpan(21));
103+
return cmd;
104+
}
105+
106+
private static int Locate(byte[] bytes)
107+
{
108+
var idx = IndexOf(bytes, Marker);
109+
if (idx < 7 || idx + 16 > bytes.Length) return -1;
110+
if (bytes[idx - 7] != 0x03 || bytes[idx - 6] != 0xE7 || bytes[idx - 5] != 0x04) return -1;
111+
if (bytes[idx + 14] != 0x0F || bytes[idx + 15] != 0xA0) return -1;
112+
return idx;
113+
}
114+
115+
private static int IndexOf(byte[] hay, byte[] needle)
116+
{
117+
for (var i = 0; i <= hay.Length - needle.Length; i++)
118+
{
119+
var ok = true;
120+
for (var j = 0; j < needle.Length; j++)
121+
{
122+
if (hay[i + j] != needle[j]) { ok = false; break; }
123+
}
124+
if (ok) return i;
125+
}
126+
return -1;
127+
}
128+
}
129+

ChuChartManager/AudioHelper.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ public float Volume
4040
return File.Exists(awbPath) ? awbPath : null;
4141
}
4242

43+
public static string? FindAcbPath(MusicXml music)
44+
{
45+
var sourceRoot = Path.GetDirectoryName(Path.GetDirectoryName(music.MusicDirectory));
46+
if (sourceRoot == null) return null;
47+
48+
var acbPath = Path.Combine(sourceRoot, "cueFile", $"cueFile{music.Id:D6}", $"{music.CueFileName}.acb");
49+
return File.Exists(acbPath) ? acbPath : null;
50+
}
51+
4352
public static byte[]? ExtractHcaFromAwb(string awbPath)
4453
{
4554
var archive = new CriAfs2Archive();

ChuChartManager/ChuChartManager.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
<None Include="icon.ico" CopyToOutputDirectory="PreserveNewest" />
3232
<None Include="tools\ugctool.exe" CopyToOutputDirectory="PreserveNewest" />
3333
<None Include="Resources\template_music.acb" CopyToOutputDirectory="PreserveNewest" />
34+
<None Include="Resources\template_preview.acb" CopyToOutputDirectory="PreserveNewest" />
3435
<Content Include="Resources\AppleChu\*.toml" CopyToOutputDirectory="PreserveNewest" />
3536
<None Include="..\FreeMote\FreeMote.Tools.Viewer\bin\Release\net48\**" Link="tools\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
3637
<None Include="..\FreeMote\FreeMote.Tools.PsbDecompile\bin\Release\net48\**" Link="tools\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />

ChuChartManager/Controllers/MusicController.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,44 @@ public ActionResult ExportMp3([FromQuery] int id, [FromQuery] string assetDir)
250250
return File(mp3Stream.ToArray(), "audio/mpeg", $"{music.CueFileName}.mp3");
251251
}
252252

253+
[HttpGet]
254+
public ActionResult<AcbPreviewHelper.PreviewTime> GetAudioPreview([FromQuery] int id, [FromQuery] string assetDir)
255+
{
256+
var scanner = scannerService.Scanner;
257+
if (scanner == null) return NotFound();
258+
259+
var music = FindMusic(scanner, id, assetDir);
260+
if (music == null) return NotFound();
261+
262+
var acbPath = AudioHelper.FindAcbPath(music);
263+
if (acbPath == null) return NotFound("未找到 ACB");
264+
265+
return Ok(AcbPreviewHelper.Read(acbPath) ?? new AcbPreviewHelper.PreviewTime(-1, -1));
266+
}
267+
268+
public record SetAudioPreviewDto(double StartMs, double EndMs);
269+
270+
[HttpPost]
271+
public ActionResult SetAudioPreview([FromQuery] int id, [FromQuery] string assetDir, [FromBody] SetAudioPreviewDto dto)
272+
{
273+
if (assetDir == "A000") return BadRequest("不能修改 A000 的曲目");
274+
275+
var scanner = scannerService.Scanner;
276+
if (scanner == null) return NotFound();
277+
278+
var music = FindMusic(scanner, id, assetDir);
279+
if (music == null) return NotFound();
280+
281+
var acbPath = AudioHelper.FindAcbPath(music);
282+
var awbPath = AudioHelper.FindAwbPath(music);
283+
if (acbPath == null || awbPath == null) return NotFound("未找到 ACB/AWB");
284+
285+
if (dto.EndMs <= dto.StartMs) return BadRequest("预览结束时间必须大于起点");
286+
287+
AcbPreviewHelper.Write(acbPath, awbPath, id, (uint)Math.Max(0, dto.StartMs), (uint)dto.EndMs);
288+
return Ok();
289+
}
290+
253291
[HttpPost]
254292
public ActionResult CopyMusic([FromBody] CopyMusicDto dto)
255293
{

ChuChartManager/Front/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"virtua": "^0.49.1",
1919
"vue": "^3.5.0",
2020
"vue-i18n": "^11.4.2",
21-
"vue-router": "^4.5.0"
21+
"vue-router": "^4.5.0",
22+
"wavesurfer.js": "^7.12.7"
2223
},
2324
"devDependencies": {
2425
"@chenfengyuan/vue-qrcode": "^2.0.0",

ChuChartManager/Front/src/api/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ export interface BatchDeleteResult {
7878
error?: string
7979
}
8080

81+
export interface AudioPreviewTime {
82+
startMs: number
83+
endMs: number
84+
}
85+
86+
export async function getAudioPreview(id: number, assetDir: string): Promise<AudioPreviewTime> {
87+
const { data } = await apiClient.get('/api/Music/GetAudioPreview', { params: { id, assetDir } })
88+
return data
89+
}
90+
91+
export async function setAudioPreview(id: number, assetDir: string, startMs: number, endMs: number): Promise<void> {
92+
await apiClient.post('/api/Music/SetAudioPreview', { startMs, endMs }, { params: { id, assetDir } })
93+
}
94+
8195
export async function batchDelete(items: { id: number; assetDir: string }[]): Promise<BatchDeleteResult[]> {
8296
const { data } = await apiClient.post('/api/Music/BatchDelete', { ids: items })
8397
return data

0 commit comments

Comments
 (0)