Skip to content

Commit c60ff12

Browse files
committed
feat(mixtape): 接入 SoundTouch 并修复 BPM 基准
1 parent b6b021d commit c60ff12

16 files changed

Lines changed: 650 additions & 17 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
"dependencies": {
161161
"@electron-toolkit/preload": "^3.0.1",
162162
"@electron-toolkit/utils": "^4.0.0",
163+
"@soundtouchjs/audio-worklet": "^1.0.10",
163164
"audio-decode": "^2.2.2",
164165
"better-sqlite3": "^12.5.0",
165166
"better-sqlite3-multiple-ciphers": "^12.8.0",

pnpm-lock.yaml

Lines changed: 20 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust_package/index.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ export interface DecodeAudioResult {
9797
/** 错误描述(当解码失败时) */
9898
error?: string
9999
}
100+
/** SoundTouch 处理结果 */
101+
export interface SoundTouchProcessResult {
102+
/** PCM 数据(Buffer,内部为 f32 小端序) */
103+
pcmData: Buffer
104+
/** 采样率 */
105+
sampleRate: number
106+
/** 声道数 */
107+
channels: number
108+
/** 总帧数 */
109+
totalFrames: number
110+
/** 错误描述(失败时) */
111+
error?: string
112+
}
100113
/** 调性分析结果 */
101114
export interface KeyAnalysisResult {
102115
/** ID3v2 ASCII key 文本 */
@@ -294,6 +307,8 @@ export declare function calculateFileHashesWithProgress(filePaths: Array<string>
294307
* * 包含 PCM 数据和元数据的解码结果
295308
*/
296309
export declare function decodeAudioFile(filePath: string): DecodeAudioResult
310+
/** 使用 SoundTouch 对交错 PCM 做不变调变速 */
311+
export declare function processSoundtouchPcm(pcmData: Buffer, sampleRate: number, channels: number, tempoRatio: number): SoundTouchProcessResult
297312
/** 基于 PCM 计算 Mixxx RGB 波形 */
298313
export declare function computeMixxxWaveform(pcmData: Buffer, sampleRate: number, channels: number): MixxxWaveformData
299314
/** 基于 PCM 计算 Mixxx RGB 波形(指定可视采样率) */

rust_package/index.d.ts.template

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ export interface DecodeAudioResult {
3232
/** 错误描述(当解码失败时) */
3333
error?: string
3434
}
35+
/** SoundTouch 处理结果 */
36+
export interface SoundTouchProcessResult {
37+
/** PCM 数据(Buffer,内部为 f32 小端序) */
38+
pcmData: Buffer
39+
/** 采样率 */
40+
sampleRate: number
41+
/** 声道数 */
42+
channels: number
43+
/** 总帧数 */
44+
totalFrames: number
45+
/** 错误描述(失败时) */
46+
error?: string
47+
}
3548
/** 调性分析结果 */
3649
export interface KeyAnalysisResult {
3750
/** ID3v2 ASCII key 文本 */
@@ -140,6 +153,13 @@ export declare function calculateFileHashesWithProgress(
140153
* * 包含 PCM 数据和元数据的解码结果
141154
*/
142155
export declare function decodeAudioFile(filePath: string): DecodeAudioResult
156+
/** 使用 SoundTouch 对交错 PCM 做不变调变速 */
157+
export declare function processSoundtouchPcm(
158+
pcmData: Buffer,
159+
sampleRate: number,
160+
channels: number,
161+
tempoRatio: number
162+
): SoundTouchProcessResult
143163
/**
144164
* 基于 PCM 计算 Mixxx RGB 波形
145165
*

rust_package/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ if (!nativeBinding) {
310310
throw new Error(`Failed to load native binding`)
311311
}
312312

313-
const { horizontalBrowseTransportReset, horizontalBrowseTransportSetDeckState, horizontalBrowseTransportSetState, horizontalBrowseTransportSetSyncEnabled, horizontalBrowseTransportBeatsync, horizontalBrowseTransportSetLeader, horizontalBrowseTransportSetPlaying, horizontalBrowseTransportSeek, horizontalBrowseTransportSetGain, horizontalBrowseTransportSnapshot, calculateAudioHashes, calculateAudioHashesWithProgress, calculateFileHashes, calculateFileHashesWithProgress, decodeAudioFile, computeMixxxWaveform, computeMixxxWaveformWithRate, analyzeKeyFromPcm, analyzeKeyAndBpmFromPcm, dumpPioneerExportDebug, readPioneerPlaylistTree, readPioneerPreviewWaveform, readPioneerPlaylistTracks } = nativeBinding
313+
const { horizontalBrowseTransportReset, horizontalBrowseTransportSetDeckState, horizontalBrowseTransportSetState, horizontalBrowseTransportSetSyncEnabled, horizontalBrowseTransportBeatsync, horizontalBrowseTransportSetLeader, horizontalBrowseTransportSetPlaying, horizontalBrowseTransportSeek, horizontalBrowseTransportSetGain, horizontalBrowseTransportSnapshot, calculateAudioHashes, calculateAudioHashesWithProgress, calculateFileHashes, calculateFileHashesWithProgress, decodeAudioFile, processSoundtouchPcm, computeMixxxWaveform, computeMixxxWaveformWithRate, analyzeKeyFromPcm, analyzeKeyAndBpmFromPcm, dumpPioneerExportDebug, readPioneerPlaylistTree, readPioneerPreviewWaveform, readPioneerPlaylistTracks } = nativeBinding
314314

315315
module.exports.horizontalBrowseTransportReset = horizontalBrowseTransportReset
316316
module.exports.horizontalBrowseTransportSetDeckState = horizontalBrowseTransportSetDeckState
@@ -327,6 +327,7 @@ module.exports.calculateAudioHashesWithProgress = calculateAudioHashesWithProgre
327327
module.exports.calculateFileHashes = calculateFileHashes
328328
module.exports.calculateFileHashesWithProgress = calculateFileHashesWithProgress
329329
module.exports.decodeAudioFile = decodeAudioFile
330+
module.exports.processSoundtouchPcm = processSoundtouchPcm
330331
module.exports.computeMixxxWaveform = computeMixxxWaveform
331332
module.exports.computeMixxxWaveformWithRate = computeMixxxWaveformWithRate
332333
module.exports.analyzeKeyFromPcm = analyzeKeyFromPcm

rust_package/src/lib.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ mod horizontal_browse_transport;
5454
mod mixxx_waveform;
5555
mod qm_bpm;
5656
mod qm_key;
57+
mod soundtouch_native;
5758

5859
use crate::analysis_utils::{calc_frames_to_process, to_stereo, K_ANALYSIS_FRAMES_PER_CHUNK};
5960
pub use crate::horizontal_browse_transport::*;
@@ -103,6 +104,21 @@ pub struct DecodeAudioResult {
103104
pub error: Option<String>,
104105
}
105106

107+
/// SoundTouch 处理结果
108+
#[napi(object)]
109+
pub struct SoundTouchProcessResult {
110+
/// PCM 数据(Buffer,内部为 f32 小端序)
111+
pub pcm_data: Buffer,
112+
/// 采样率
113+
pub sample_rate: u32,
114+
/// 声道数
115+
pub channels: u8,
116+
/// 总帧数
117+
pub total_frames: u32,
118+
/// 错误描述(失败时)
119+
pub error: Option<String>,
120+
}
121+
106122
/// 调性分析结果
107123
#[napi(object)]
108124
pub struct KeyAnalysisResult {
@@ -476,6 +492,49 @@ pub fn decode_audio_file(file_path: String) -> DecodeAudioResult {
476492
}
477493
}
478494

495+
/// 使用 SoundTouch 对交错 PCM 做不变调变速
496+
#[napi]
497+
pub fn process_soundtouch_pcm(
498+
pcm_data: Buffer,
499+
sample_rate: u32,
500+
channels: u8,
501+
tempo_ratio: f64,
502+
) -> napi::Result<SoundTouchProcessResult> {
503+
let pcm_bytes = pcm_data.as_ref();
504+
let pcm_f32 = try_cast_slice::<u8, f32>(pcm_bytes).map_err(|_| {
505+
Error::from_reason("PCM buffer length is not aligned to f32 for SoundTouch processing")
506+
})?;
507+
let safe_channels = usize::from(channels.max(1));
508+
if pcm_f32.len() % safe_channels != 0 {
509+
return Ok(SoundTouchProcessResult {
510+
pcm_data: Buffer::from(Vec::<u8>::new()),
511+
sample_rate,
512+
channels,
513+
total_frames: 0,
514+
error: Some("PCM buffer is not aligned to channel count".to_string()),
515+
});
516+
}
517+
match soundtouch_native::process_interleaved_f32(pcm_f32, sample_rate, safe_channels, tempo_ratio) {
518+
Ok(processed) => {
519+
let total_frames = (processed.len() / safe_channels) as u32;
520+
Ok(SoundTouchProcessResult {
521+
pcm_data: Buffer::from(cast_slice(&processed).to_vec()),
522+
sample_rate,
523+
channels,
524+
total_frames,
525+
error: None,
526+
})
527+
}
528+
Err(message) => Ok(SoundTouchProcessResult {
529+
pcm_data: Buffer::from(Vec::<u8>::new()),
530+
sample_rate,
531+
channels,
532+
total_frames: 0,
533+
error: Some(message),
534+
}),
535+
}
536+
}
537+
479538
/// 基于 PCM 计算 Mixxx RGB 波形
480539
#[napi]
481540
pub fn compute_mixxx_waveform(
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
use std::ffi::c_void;
2+
3+
const ST_SETTING_USE_QUICKSEEK: i32 = 2;
4+
const MIN_RATE: f64 = 0.25;
5+
const MAX_RATE: f64 = 4.0;
6+
const RECEIVE_CHUNK_FRAMES: usize = 4096;
7+
8+
unsafe extern "C" {
9+
fn frkb_soundtouch_create() -> *mut c_void;
10+
fn frkb_soundtouch_destroy(handle: *mut c_void);
11+
fn frkb_soundtouch_set_channels(handle: *mut c_void, channels: u32);
12+
fn frkb_soundtouch_set_sample_rate(handle: *mut c_void, sample_rate: u32);
13+
fn frkb_soundtouch_set_tempo(handle: *mut c_void, tempo: f64);
14+
fn frkb_soundtouch_set_pitch(handle: *mut c_void, pitch: f64);
15+
fn frkb_soundtouch_set_rate(handle: *mut c_void, rate: f64);
16+
fn frkb_soundtouch_set_setting(handle: *mut c_void, setting_id: i32, value: i32);
17+
fn frkb_soundtouch_put_samples(handle: *mut c_void, samples: *const f32, num_samples: u32);
18+
fn frkb_soundtouch_receive_samples(
19+
handle: *mut c_void,
20+
output: *mut f32,
21+
max_samples: u32,
22+
) -> u32;
23+
fn frkb_soundtouch_flush(handle: *mut c_void);
24+
}
25+
26+
fn clamp_rate(value: f64) -> f64 {
27+
if value.is_finite() && value > 0.0 {
28+
value.clamp(MIN_RATE, MAX_RATE)
29+
} else {
30+
1.0
31+
}
32+
}
33+
34+
pub(crate) struct SoundTouchHandle(*mut c_void);
35+
36+
unsafe impl Send for SoundTouchHandle {}
37+
38+
impl SoundTouchHandle {
39+
pub(crate) fn new(channels: u32, sample_rate: u32, tempo: f64) -> Option<Self> {
40+
let handle = unsafe { frkb_soundtouch_create() };
41+
if handle.is_null() {
42+
return None;
43+
}
44+
unsafe {
45+
frkb_soundtouch_set_channels(handle, channels);
46+
frkb_soundtouch_set_sample_rate(handle, sample_rate);
47+
frkb_soundtouch_set_tempo(handle, tempo);
48+
frkb_soundtouch_set_pitch(handle, 1.0);
49+
frkb_soundtouch_set_rate(handle, 1.0);
50+
frkb_soundtouch_set_setting(handle, ST_SETTING_USE_QUICKSEEK, 1);
51+
}
52+
Some(Self(handle))
53+
}
54+
55+
pub(crate) fn put_samples(&mut self, samples: &[f32], num_samples: usize) {
56+
if samples.is_empty() || num_samples == 0 {
57+
return;
58+
}
59+
unsafe { frkb_soundtouch_put_samples(self.0, samples.as_ptr(), num_samples as u32) }
60+
}
61+
62+
pub(crate) fn receive_samples(&mut self, output: &mut [f32], max_samples: usize) -> usize {
63+
if output.is_empty() || max_samples == 0 {
64+
return 0;
65+
}
66+
unsafe { frkb_soundtouch_receive_samples(self.0, output.as_mut_ptr(), max_samples as u32) as usize }
67+
}
68+
69+
pub(crate) fn flush(&mut self) {
70+
unsafe { frkb_soundtouch_flush(self.0) }
71+
}
72+
}
73+
74+
impl Drop for SoundTouchHandle {
75+
fn drop(&mut self) {
76+
if !self.0.is_null() {
77+
unsafe { frkb_soundtouch_destroy(self.0) }
78+
self.0 = std::ptr::null_mut();
79+
}
80+
}
81+
}
82+
83+
pub(crate) fn process_interleaved_f32(
84+
samples: &[f32],
85+
sample_rate: u32,
86+
channels: usize,
87+
tempo: f64,
88+
) -> Result<Vec<f32>, String> {
89+
let safe_channels = channels.max(1);
90+
if sample_rate == 0 {
91+
return Err("sample_rate is 0".to_string());
92+
}
93+
if samples.is_empty() {
94+
return Ok(Vec::new());
95+
}
96+
let safe_tempo = clamp_rate(tempo);
97+
if (safe_tempo - 1.0).abs() <= 0.0001 {
98+
return Ok(samples.to_vec());
99+
}
100+
let mut processor = SoundTouchHandle::new(safe_channels as u32, sample_rate, safe_tempo)
101+
.ok_or_else(|| "create soundtouch failed".to_string())?;
102+
processor.put_samples(samples, samples.len() / safe_channels);
103+
104+
let mut output = Vec::<f32>::with_capacity(
105+
((samples.len() as f64 / safe_tempo.max(0.01)) as usize).saturating_add(safe_channels * 1024),
106+
);
107+
let mut chunk = vec![0.0_f32; safe_channels * RECEIVE_CHUNK_FRAMES];
108+
109+
loop {
110+
let received = processor.receive_samples(&mut chunk, RECEIVE_CHUNK_FRAMES);
111+
if received == 0 {
112+
break;
113+
}
114+
output.extend_from_slice(&chunk[..received * safe_channels]);
115+
}
116+
117+
processor.flush();
118+
119+
loop {
120+
let received = processor.receive_samples(&mut chunk, RECEIVE_CHUNK_FRAMES);
121+
if received == 0 {
122+
break;
123+
}
124+
output.extend_from_slice(&chunk[..received * safe_channels]);
125+
}
126+
127+
Ok(output)
128+
}

0 commit comments

Comments
 (0)