Skip to content

Commit 9adf696

Browse files
committed
feat: 替换音频支持偏移对齐
c2s 已编译谱面无偏移字段,换音源时开头空白对不上只能移音频。 替换音频时可填偏移秒数:正值开头插静音、负值裁掉开头,编码 HCA 前应用。 拖放替换仍为 0 偏移,.awb 直传跳过(已编码无法处理)
1 parent b365810 commit 9adf696

7 files changed

Lines changed: 68 additions & 22 deletions

File tree

ChuChartManager/AudioHelper.cs

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using ChuChartManager.Models;
22
using SonicAudioLib.Archives;
33
using NAudio.Wave;
4+
using NAudio.Wave.SampleProviders;
45
using NAudio.Lame;
56
using VGAudio.Codecs.CriHca;
67
using VGAudio.Containers.Hca;
@@ -190,22 +191,9 @@ public static void ExportMp3(byte[] wavData, string outputPath)
190191
return ConvertStream.ConvertFile(options, wavStream, FileType.Wave, FileType.Hca);
191192
}
192193

193-
public static void ImportAudioToMusic(MusicXml music, string audioPath)
194+
public static void ImportAudioToMusic(MusicXml music, string audioPath, float padding = 0)
194195
{
195-
byte[] wavBytes;
196-
var ext = Path.GetExtension(audioPath).ToLowerInvariant();
197-
if (ext == ".wav")
198-
{
199-
wavBytes = File.ReadAllBytes(audioPath);
200-
}
201-
else
202-
{
203-
using var reader = new AudioFileReader(audioPath);
204-
using var wavMs = new MemoryStream();
205-
var pcm16 = reader.ToWaveProvider16();
206-
WaveFileWriter.WriteWavFileToStream(wavMs, pcm16);
207-
wavBytes = wavMs.ToArray();
208-
}
196+
var wavBytes = ConvertToWav(audioPath, padding);
209197

210198
var hcaBytes = EncodeWavToHca(wavBytes);
211199
if (hcaBytes == null || hcaBytes.Length == 0)
@@ -221,6 +209,29 @@ public static void ImportAudioToMusic(MusicXml music, string audioPath)
221209
RepackAcbWithHca(cueFileDir, cueFileName, hcaBytes);
222210
}
223211

212+
// padding>0 开头插静音(声音延后);padding<0 裁掉开头(声音提前),用于对齐已编译谱面
213+
private static byte[] ConvertToWav(string audioPath, float padding)
214+
{
215+
if (padding == 0 && Path.GetExtension(audioPath).Equals(".wav", StringComparison.OrdinalIgnoreCase))
216+
return File.ReadAllBytes(audioPath);
217+
218+
using var reader = new AudioFileReader(audioPath);
219+
ISampleProvider sample = reader;
220+
if (padding > 0)
221+
{
222+
var silence = new SilenceProvider(reader.WaveFormat).ToSampleProvider().Take(TimeSpan.FromSeconds(padding));
223+
sample = silence.FollowedBy(sample);
224+
}
225+
else if (padding < 0)
226+
{
227+
sample = sample.Skip(TimeSpan.FromSeconds(-padding));
228+
}
229+
230+
using var ms = new MemoryStream();
231+
WaveFileWriter.WriteWavFileToStream(ms, sample.ToWaveProvider16());
232+
return ms.ToArray();
233+
}
234+
224235
public static void RepackAcbWithHca(string cueFileDir, string cueFileName, byte[] hcaBytes)
225236
{
226237
var acbTemplatePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "template_music.acb");

ChuChartManager/Controllers/MusicController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@ public ActionResult SetJacket([FromQuery] int id, [FromQuery] string assetDir, I
444444

445445
[HttpPut]
446446
[DisableRequestSizeLimit]
447-
public ActionResult SetAudio([FromQuery] int id, [FromQuery] string assetDir, IFormFile file)
447+
public ActionResult SetAudio([FromQuery] int id, [FromQuery] string assetDir, IFormFile file, [FromQuery] float padding = 0)
448448
{
449449
var scanner = scannerService.Scanner;
450450
if (scanner == null) return NotFound();
@@ -470,7 +470,7 @@ public ActionResult SetAudio([FromQuery] int id, [FromQuery] string assetDir, IF
470470
}
471471
else
472472
{
473-
AudioHelper.ImportAudioToMusic(music, tempPath);
473+
AudioHelper.ImportAudioToMusic(music, tempPath, padding);
474474
}
475475
}
476476
finally

ChuChartManager/Front/src/api/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,10 @@ export async function setJacket(id: number, assetDir: string, file: File): Promi
244244
await apiClient.put(`/api/Music/SetJacket?id=${id}&assetDir=${assetDir}`, form)
245245
}
246246

247-
export async function setAudio(id: number, assetDir: string, file: File): Promise<void> {
247+
export async function setAudio(id: number, assetDir: string, file: File, padding = 0): Promise<void> {
248248
const form = new FormData()
249249
form.append('file', file)
250-
await apiClient.put(`/api/Music/SetAudio?id=${id}&assetDir=${assetDir}`, form, { timeout: 120000 })
250+
await apiClient.put(`/api/Music/SetAudio?id=${id}&assetDir=${assetDir}&padding=${padding}`, form, { timeout: 120000 })
251251
}
252252

253253
export async function replaceChart(id: number, assetDir: string, diffIndex: number, file: File): Promise<{ imported: boolean; convertedFrom?: string; alerts?: string[] }> {

ChuChartManager/Front/src/locales/en.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ music:
206206
exportFailed: "Export failed: {error}"
207207
clickToReplaceJacket: Click to replace jacket
208208
replaceAudio: Replace Audio
209+
audioOffset: Audio Offset
210+
audioOffsetHint: Positive inserts silence at the start (delays audio), negative trims the start (advances audio), in seconds. Use to align audio with the chart; leave 0 if unsure
209211
editPreview: Edit Preview
210212
previewHint: Drag the region to adjust; Ctrl+click sets start / Shift+click sets end; set precisely below; scroll to zoom
211213
previewStart: Start (s)

ChuChartManager/Front/src/locales/ja.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ music:
206206
exportFailed: "エクスポート失敗: {error}"
207207
clickToReplaceJacket: クリックでジャケットを置換
208208
replaceAudio: 音声を置換
209+
audioOffset: 音声オフセット
210+
audioOffsetHint: 正の値は先頭に無音を挿入(音声を遅らせる)、負の値は先頭を削る(音声を早める)、単位は秒。譜面と音源の同期に使用、不明なら 0
209211
editPreview: 試聴を編集
210212
previewHint: 範囲をドラッグして調整、Ctrl+クリックで開始 / Shift+クリックで終了、下の入力欄で精密設定、ホイールでズーム
211213
previewStart: 開始(秒)

ChuChartManager/Front/src/locales/zh.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ music:
206206
exportFailed: "导出失败: {error}"
207207
clickToReplaceJacket: 点击替换封面
208208
replaceAudio: 替换音频
209+
audioOffset: 音频偏移
210+
audioOffsetHint: 正值在开头插入静音(声音延后),负值裁掉开头(声音提前),单位秒。用于对齐音源与谱面,不确定就填 0
209211
editPreview: 编辑试听
210212
previewHint: 拖动选区调整试听区间,Ctrl+点击设起点 / Shift+点击设终点,或用下方输入框精确设置;滚轮缩放波形
211213
previewStart: 起点(秒)

ChuChartManager/Front/src/views/MusicList.vue

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,17 +296,21 @@ async function handleSetJacket() {
296296
297297
const showAudioOverlay = ref(false)
298298
299-
async function applyAudio(file: File) {
299+
async function applyAudio(file: File, padding = 0) {
300300
if (!selectedMusic.value) return
301301
setStatus(t('music.audioImporting'))
302302
try {
303-
await setAudio(selectedMusic.value.id, selectedMusic.value.assetDir, file)
303+
await setAudio(selectedMusic.value.id, selectedMusic.value.assetDir, file, padding)
304304
setStatus(t('music.audioReplaced'))
305305
} catch (e: any) {
306306
setStatus(t('music.audioReplaceFailed', { error: e?.response?.data || e?.message }))
307307
}
308308
}
309309
310+
const showOffsetModal = ref(false)
311+
const audioOffset = ref(0)
312+
let pendingAudioFile: File | null = null
313+
310314
async function handleReplaceAudio() {
311315
if (!selectedMusic.value) return
312316
showAudioOverlay.value = true
@@ -321,7 +325,22 @@ async function handleReplaceAudio() {
321325
return
322326
}
323327
showAudioOverlay.value = false
324-
await applyAudio(await fileHandle.getFile())
328+
pendingAudioFile = await fileHandle.getFile()
329+
if (pendingAudioFile.name.toLowerCase().endsWith('.awb')) {
330+
await applyAudio(pendingAudioFile)
331+
pendingAudioFile = null
332+
return
333+
}
334+
audioOffset.value = 0
335+
showOffsetModal.value = true
336+
}
337+
338+
async function confirmAudioOffset() {
339+
showOffsetModal.value = false
340+
if (!pendingAudioFile) return
341+
const file = pendingAudioFile
342+
pendingAudioFile = null
343+
await applyAudio(file, audioOffset.value)
325344
}
326345
327346
const showChartOverlay = ref(false)
@@ -669,6 +688,16 @@ const copyExportOptions = computed(() => {
669688
<div v-else class="flex-1 flex items-center justify-center op-30 text-lg">{{ t('music.selectHint') }}</div>
670689
</div>
671690

691+
<Modal v-model:show="showOffsetModal" :title="t('music.audioOffset')" width="min(40vw,28em)">
692+
<div class="flex flex-col gap-3">
693+
<div class="text-sm op-60">{{ t('music.audioOffsetHint') }}</div>
694+
<NumberInput v-model:value="audioOffset" :step="0.001" :decimal="3" class="w-full" />
695+
</div>
696+
<template #actions>
697+
<Button class="w-0 grow" @click="confirmAudioOffset">{{ t('common.confirm') }}</Button>
698+
</template>
699+
</Modal>
700+
672701
<Modal v-model:show="showChangeId" :title="t('music.changeId')" width="min(30vw,25em)">
673702
<div class="flex flex-col gap-3">
674703
<div>

0 commit comments

Comments
 (0)