feat(anvil): add Ghost harmonics and Phone listen

Ghost adds the sub's harmonics 2–10 above Ghost from, so phones hear the
note. The band above the split is measured per harmonic and only missing
harmonics are filled, so added ones cannot cancel the patch's own. The sub
level does not change. Phone monitors the output in mono, 400 Hz–7 kHz.
This commit is contained in:
nvrl committed 2026-11-06 13:46:15 +01:00
1 parent 4f1e237d66
commit e7f3346077
12 files changed
+489 -50

No files matched your search

+101 -6
View File
@@ -1,5 +1,5 @@
//! Anvil editor, shared by the CLAP wrapper and the headless renderer.
use anvil_core::{AnalyzerFrame, Settings, Source, SCOPE};
use anvil_core::{AnalyzerFrame, Settings, Source, GHOST_COUNT, PHONE_LOW, SCOPE};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, RichText, Sense, Stroke, Vec2};
@@ -9,6 +9,8 @@ pub const HEIGHT: u32 = 704;
pub struct State {
pub solo: bool,
/// Phone-like listen. A monitor, never saved.
pub phone: bool,
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
@@ -18,6 +20,7 @@ impl Default for State {
fn default() -> Self {
Self {
solo: false,
phone: false,
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
@@ -64,7 +67,63 @@ fn toggle(ui: &mut egui::Ui, state: &mut State, on: &mut bool, label: &str, help
}
hint(&r, state, help);
}
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
/// Harmonics 2–10 of the current note: what the patch carries above the split (steel)
/// and what Ghost adds (accent), from −48 dB to 0 dB against the sub.
fn harmonics(p: &egui::Painter, rect: egui::Rect, frame: &AnalyzerFrame) {
p.rect_filled(rect, 2., Color32::from_rgba_unmultiplied(12, 13, 16, 230));
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
p.text(
rect.left_top() + Vec2::new(8., 6.),
egui::Align2::LEFT_TOP,
"GHOST",
FontId::proportional(10.),
ACCENT,
);
p.text(
rect.right_top() + Vec2::new(-8., 6.),
egui::Align2::RIGHT_TOP,
format!("from {:.0} Hz", frame.ghost_edge),
FontId::proportional(10.),
MUTED,
);
let area = egui::Rect::from_min_max(
rect.left_top() + Vec2::new(8., 22.),
rect.right_bottom() - Vec2::new(8., 16.),
);
let slot = area.width() / GHOST_COUNT as f32;
let height = |db: f32| ((db + 48.) / 48.).clamp(0., 1.) * area.height();
for i in 0..GHOST_COUNT {
let x = area.left() + slot * i as f32;
let bar = |left: f32, db: f32, color: Color32| {
let h = height(db);
if h > 0.5 {
p.rect_filled(
egui::Rect::from_min_max(
Pos2::new(left, area.bottom() - h),
Pos2::new(left + slot * 0.36, area.bottom()),
),
1.,
color,
);
}
};
bar(x + slot * 0.12, frame.patch_harmonics[i], STEEL);
bar(x + slot * 0.52, frame.ghost_harmonics[i], ACCENT);
p.text(
Pos2::new(x + slot * 0.5, area.bottom() + 2.),
egui::Align2::CENTER_TOP,
format!("{}", i + 2),
FontId::proportional(9.),
DIM,
);
}
}
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32, ghost: bool) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
@@ -123,6 +182,11 @@ fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
FontId::proportional(11.),
MUTED,
);
if ghost {
let size = Vec2::new(200., 92.);
let corner = rect.right_bottom() - Vec2::new(8., 8.);
harmonics(&p, egui::Rect::from_min_max(corner - size, corner), frame);
}
p.rect_stroke(
rect,
2.,
@@ -145,10 +209,18 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
}
let r = ui
.selectable_label(state.solo, RichText::new("SOLO SUB").size(13.))
.on_hover_text("Hear only the generated sub.");
.on_hover_text("Hear only the generated sub and Ghost harmonics.");
if r.clicked() {
state.solo = !state.solo;
}
let r = ui
.selectable_label(state.phone, RichText::new("PHONE").size(13.))
.on_hover_text(format!(
"Listen as on a phone: mono, nothing below {PHONE_LOW:.0} Hz. Not saved."
));
if r.clicked() {
state.phone = !state.phone;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
@@ -169,7 +241,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
scope(ui, &frame, TOP);
scope(ui, &frame, TOP, settings.ghost > 0.);
});
});
ui.add_space(6.);
@@ -212,7 +284,7 @@ fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let defaults = Settings::default();
ui.columns(4, |c| {
ui.columns(6, |c| {
knob(
&mut c[0],
state,
@@ -255,7 +327,30 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
defaults.drive,
" %",
88.,
"Adds upper harmonics so the sub reads on small speakers.",
"Saturates the sine: odd harmonics, thicker sub. Changes the sub itself.",
);
knob(
&mut c[4],
state,
"Ghost",
&mut settings.ghost,
0. ..=100.,
defaults.ghost,
" %",
88.,
"Adds the sub's harmonics 2–10 above Ghost from, so phones hear the note. \
Only harmonics the patch lacks are added. The sub itself stays the same.",
);
knob(
&mut c[5],
state,
"Ghost from",
&mut settings.ghost_from,
150. ..=600.,
defaults.ghost_from,
" Hz",
88.,
"Lowest frequency Ghost adds. Phones cut off around 300–500 Hz. Never below Split.",
);
});
ui.add_space(6.);