Skip to content

Commit 28824e2

Browse files
authored
feat(nam): bypass model on sample-rate mismatch and surface it in the UI (#244)
1 parent 5a03229 commit 28824e2

6 files changed

Lines changed: 82 additions & 8 deletions

File tree

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@
44

55
/claude
66

7+
# Local working material (not part of the repo)
8+
/docs
9+
/dsp_reference
10+
/.superpowers
11+
/.claude
12+
13+
# NAM model files are user-provided; only the placeholder is tracked
14+
/nam/*
15+
!/nam/.gitkeep
16+
717
impulse_responses/*
818
!/impulse_responses/Misc
919
!/impulse_responses/Jesterdyne

rustortion-core/src/amp/stages/nam.rs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ impl NamStage {
3636
sample_rate_mismatch: false,
3737
}
3838
}
39+
40+
/// Passthrough used when the model's native rate mismatches the engine rate.
41+
/// Carries the real native rate so the UI/params can report the bypass reason.
42+
const fn bypassed_for_mismatch(
43+
input_gain: f32,
44+
output_gain: f32,
45+
mix: f32,
46+
native_sample_rate: f32,
47+
) -> Self {
48+
Self {
49+
wavenet: None,
50+
input_gain,
51+
output_gain,
52+
mix,
53+
native_sample_rate,
54+
sample_rate_mismatch: true,
55+
}
56+
}
3957
}
4058

4159
impl Stage for NamStage {
@@ -135,11 +153,18 @@ impl NamConfig {
135153
};
136154

137155
let native_sample_rate = model.sample_rate() as f32;
138-
let sample_rate_mismatch = (native_sample_rate - sample_rate).abs() > 1.0;
139-
if sample_rate_mismatch {
156+
if (native_sample_rate - sample_rate).abs() > 1.0 {
157+
// Resampling is intentionally avoided (too expensive on the RT path), so a
158+
// rate mismatch bypasses the model entirely: pass the dry signal through.
140159
warn!(
141160
"NAM model '{name}' native rate {native_sample_rate} Hz differs from engine \
142-
rate {sample_rate} Hz; tone may be affected"
161+
rate {sample_rate} Hz; bypassing model (dry passthrough)"
162+
);
163+
return NamStage::bypassed_for_mismatch(
164+
input_gain,
165+
output_gain,
166+
mix,
167+
native_sample_rate,
143168
);
144169
}
145170

@@ -150,7 +175,8 @@ impl NamConfig {
150175
output_gain,
151176
mix,
152177
native_sample_rate,
153-
sample_rate_mismatch,
178+
// Rates match (mismatch returned early above).
179+
sample_rate_mismatch: false,
154180
},
155181
Err(e) => {
156182
warn!("Failed to build NAM model '{name}': {e}; using passthrough");
@@ -173,6 +199,25 @@ mod tests {
173199
}
174200
}
175201

202+
#[test]
203+
fn mismatch_bypass_is_dry_passthrough() {
204+
// A rate-mismatch stage is built without a WaveNet but records the real native
205+
// rate and the mismatch flag. We construct it directly here because building a
206+
// real `WaveNet` requires loading a `.nam` model into the registry, which unit
207+
// tests can't do; this still verifies the RT-path passthrough contract and the
208+
// params reported to the UI.
209+
let mut stage =
210+
NamStage::bypassed_for_mismatch(db_to_lin(6.0), db_to_lin(-3.0), 0.5, 44_100.0);
211+
212+
// No model runs: output is the dry input, with no gain or mix applied.
213+
for x in [-1.0, 0.0, 0.25, 0.9] {
214+
assert_eq!(stage.process(x), x);
215+
}
216+
217+
assert!((stage.get_parameter("native_sample_rate").unwrap() - 44_100.0).abs() < 1e-3);
218+
assert!((stage.get_parameter("sample_rate_mismatch").unwrap() - 1.0).abs() < 1e-6);
219+
}
220+
176221
#[test]
177222
fn gain_and_mix_round_trip() {
178223
let mut stage = NamConfig::default().to_stage(48_000.0);

rustortion-ui/src/app.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,10 @@ impl<B: ParamBackend> SharedApp<B> {
433433
can_move_up,
434434
can_move_down,
435435
bypassed,
436+
// Effective rate (device × oversampling) — the rate stages are
437+
// built at, so NAM's mismatch check compares against the right value.
438+
engine_sample_rate: self.backend.sample_rate()
439+
* self.backend.oversampling_factor(),
436440
},
437441
));
438442
}

rustortion-ui/src/components/widgets/common.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ pub struct StageViewState {
104104
pub can_move_up: bool,
105105
pub can_move_down: bool,
106106
pub bypassed: bool,
107+
/// Effective engine sample rate in Hz — the device rate times the oversampling
108+
/// factor, i.e. the rate stages are actually built and run at. Used by stages
109+
/// (e.g. NAM) to detect rate mismatches, so it must match what `to_stage` sees.
110+
pub engine_sample_rate: u32,
107111
}
108112

109113
fn stage_header(stage_name: &str, idx: usize, state: StageViewState) -> Element<'_, Message> {

rustortion-ui/src/i18n/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ pub struct Translations {
167167
pub nam_model: &'static str,
168168
pub nam_no_model: &'static str,
169169
pub nam_native_rate: &'static str,
170+
pub nam_rate_mismatch_bypassed: &'static str,
170171
pub nam_model_not_found: &'static str,
171172
pub nam_input_gain: &'static str,
172173
pub nam_output_gain: &'static str,
@@ -369,6 +370,7 @@ pub static EN: Translations = Translations {
369370
nam_model: "Model",
370371
nam_no_model: "Select a model…",
371372
nam_native_rate: "Native rate",
373+
nam_rate_mismatch_bypassed: "Bypassed — model rate vs engine rate",
372374
nam_model_not_found: "Model not found",
373375
nam_input_gain: "Input",
374376
nam_output_gain: "Output",
@@ -562,6 +564,7 @@ pub static ZH_CN: Translations = Translations {
562564
nam_model: "模型",
563565
nam_no_model: "选择模型…",
564566
nam_native_rate: "原始采样率",
567+
nam_rate_mismatch_bypassed: "已旁通 — 模型采样率 vs 引擎采样率",
565568
nam_model_not_found: "未找到模型",
566569
nam_input_gain: "输入",
567570
nam_output_gain: "输出",

rustortion-ui/src/stages/nam.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,21 @@ pub fn view(idx: usize, cfg: &NamConfig, state: StageViewState) -> Element<'_, M
103103
.align_y(Alignment::Center);
104104

105105
// Read-only info: the selected model's native sample rate (or "not found").
106-
// A mismatch against the engine rate is logged at build time; the engine rate
107-
// isn't available in this view, so it isn't shown here.
106+
// When the native rate mismatches the engine rate the model is bypassed (dry
107+
// passthrough, no resampling) — surface that here using both rates.
108+
let engine_rate = state.engine_sample_rate;
108109
let info_line: Element<'_, Message> = match model_name.as_deref() {
109110
Some(name) => match registry::get(name) {
110111
Some(model) => {
111-
let rate = model.sample_rate() as u32;
112-
text(format!("{}: {rate} Hz", tr!(nam_native_rate)))
112+
let native_rate = model.sample_rate() as u32;
113+
if native_rate.abs_diff(engine_rate) > 1 {
114+
text(format!(
115+
"{}: {native_rate} Hz · {engine_rate} Hz",
116+
tr!(nam_rate_mismatch_bypassed)
117+
))
118+
} else {
119+
text(format!("{}: {native_rate} Hz", tr!(nam_native_rate)))
120+
}
113121
}
114122
None => text(tr!(nam_model_not_found)),
115123
}

0 commit comments

Comments
 (0)