Skip to content

Commit f42e246

Browse files
Show parameters in real units
HelixControls.json was only feeding enum labels, so every continuous param showed its raw DSP value. A delay time read 1.3728 with no hint it meant 1.4 seconds, which cost the tester part of a session chasing a delay he thought was broken. The same file already had the recipe: scale, range-switched formats and unit templates. ParamMeta carries it now; the CLI keeps the raw value alongside since that is what `set` takes, and the GUI formats from rules in the DTO so it still works mid-drag.
1 parent 757db68 commit f42e246

5 files changed

Lines changed: 331 additions & 5 deletions

File tree

STATUS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1627,3 +1627,26 @@ bug.
16271627
path B, then raise `Balance B` or its Sensitivity and listen. `captures/_RUNBOOK-hx-edit-session.md`
16281628
is unchanged and still the list for the next HX Edit session — the node move stays top of it, since
16291629
12% of writes still wedge and we have never watched HX Edit perform that edit.
1630+
1631+
## Parameters read in real units (2026-08-03)
1632+
1633+
`HelixControls.json` was only being used for enum labels. Every continuous parameter showed its raw
1634+
DSP value, which is how the tester spent part of a session on an Adriatic Delay reading `1.3728`,
1635+
couldn't tell what it meant, and nearly filed it as broken:
1636+
1637+
> fiddled with the adriatic delay, the time was was too long, almost made me think it wasn't working
1638+
1639+
It was a 1.4-second delay. The same file already held the recipe to say so — a `dspToDisplayScale`,
1640+
range-switched `format` rules, and `formatUnits` templates — so `ParamMeta` now carries it and both
1641+
front ends apply it:
1642+
1643+
[ 0] Time = 1.373 s [1.3728]
1644+
[ 1] Feedback = 50 % [0.5]
1645+
[ 5] Level = +0.0 dB [0]
1646+
[ 9] SyncSelect1 = 1/4 Triplet [6]
1647+
1648+
The CLI keeps the raw value in brackets because that is what `fretwire set` takes. The GUI formats
1649+
client-side, from rules sent in the param DTO, because a slider re-renders on every drag frame
1650+
before any value reaches Rust. Aliases are resolved (`time_ms_20_1800``time_ms`), ranges pick
1651+
their own units (ms under a second, seconds past it), and anything the reference data doesn't
1652+
describe falls back to the bare number.

crates/fretwire-cli/src/main.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1072,7 +1072,7 @@ fn print_params(params: &[fretwire_core::editor::EditorParam]) {
10721072
" [{:>2}] {:<14} = {}{}",
10731073
p.index,
10741074
p.name,
1075-
fmt_value(p.value),
1075+
fmt_param(p),
10761076
if p.settable {
10771077
""
10781078
} else {
@@ -1238,6 +1238,25 @@ fn print_preset(preset: &fretwire_core::EditorPreset) {
12381238
}
12391239
}
12401240

1241+
/// A parameter as a human reads it, with the raw value kept alongside because that is what
1242+
/// `fretwire set` takes: `1.373 s [1.3728]`. An enum shows its label, a plain number shows alone.
1243+
fn fmt_param(p: &fretwire_core::editor::EditorParam) -> String {
1244+
use fretwire_data::stream::ParamValue::*;
1245+
let raw = fmt_value(p.value);
1246+
let pretty = match p.value {
1247+
Float(f) => p.meta.format.as_ref().and_then(|nf| nf.display(f.into())),
1248+
Int(i) => usize::try_from(i)
1249+
.ok()
1250+
.and_then(|i| p.meta.enum_labels.get(i))
1251+
.cloned(),
1252+
Bool(_) => None,
1253+
};
1254+
match pretty {
1255+
Some(s) if s != raw => format!("{s:<12} [{raw}]"),
1256+
_ => raw,
1257+
}
1258+
}
1259+
12411260
fn fmt_value(v: fretwire_data::stream::ParamValue) -> String {
12421261
use fretwire_data::stream::ParamValue::*;
12431262
match v {

crates/fretwire-core/src/editor.rs

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ pub struct ParamMeta {
6060
/// the cab mic `Angle`: 0°/45°): the allowed stops. The value written is the stop's `value`
6161
/// via the ordinary float path — the wire type stays float. Empty for continuous params.
6262
pub stops: Vec<SegStop>,
63+
/// How to display a continuous value with its unit ("1.373 s" for a stored `1.3728`). `None`
64+
/// for params whose control isn't described, and for enums/switches, which read as labels.
65+
/// See [`NumFormat`].
66+
pub format: Option<NumFormat>,
6367
}
6468

6569
/// One position of a segmented float control (see [`ParamMeta::stops`]).
@@ -973,6 +977,7 @@ fn param_meta_from(
973977
) -> std::collections::HashMap<String, std::collections::HashMap<String, ParamMeta>> {
974978
let discrete = discrete_control_labels(controls);
975979
let segmented = segmented_float_controls(controls);
980+
let numeric = numeric_control_formats(controls);
976981
let mut map: std::collections::HashMap<String, std::collections::HashMap<String, ParamMeta>> =
977982
std::collections::HashMap::new();
978983
for (_, bytes) in models {
@@ -1005,13 +1010,24 @@ fn param_meta_from(
10051010
} else {
10061011
Vec::new()
10071012
};
1013+
// Continuous floats get their unit-bearing display recipe. Enums and switches
1014+
// read as labels, and a segmented float shows its stop labels instead.
1015+
let format = if p.value_type == Some(1) {
1016+
p.display_type
1017+
.as_deref()
1018+
.and_then(|dt| numeric.get(dt))
1019+
.cloned()
1020+
} else {
1021+
None
1022+
};
10081023
let meta = ParamMeta {
10091024
min: p.min_f64(),
10101025
max: p.max_f64(),
10111026
display_type: p.display_type.clone(),
10121027
value_type: p.value_type,
10131028
enum_labels,
10141029
stops,
1030+
format,
10151031
};
10161032
(p.symbolic_id.clone(), meta)
10171033
})
@@ -1191,6 +1207,173 @@ fn seg_label(fmt: &str, v: f64) -> String {
11911207
format!("{v}")
11921208
}
11931209

1210+
/// How a numeric parameter should be shown. The device stores DSP values — a delay time is
1211+
/// `1.3728`, a mix is `0.5` — and HX Edit displays them scaled and carrying a unit ("1.373 s",
1212+
/// "50 %"). `HelixControls.json` holds that recipe, keyed by the param's `displayType`.
1213+
///
1214+
/// This is not cosmetic. The tester spent part of a session on an Adriatic Delay reading `1.3728`,
1215+
/// couldn't tell what it meant, and nearly filed it as "the delay isn't working" — it was a 1.4
1216+
/// second delay time, which "1.373 s" would have said outright.
1217+
#[derive(Debug, Clone, PartialEq)]
1218+
pub struct NumFormat {
1219+
/// `dspToDisplayScale`: stored value × this = display units (seconds → ms, 0–1 → percent).
1220+
pub scale: f64,
1221+
/// Range rules in file order; the first whose bounds contain the scaled value wins. A control
1222+
/// with one unranged format has a single rule spanning everything.
1223+
pub rules: Vec<FormatRule>,
1224+
}
1225+
1226+
/// One range of a [`NumFormat`] — the file splits a control by magnitude so that, say, a delay
1227+
/// reads in ms up to a second and in seconds past it.
1228+
#[derive(Debug, Clone, PartialEq)]
1229+
pub struct FormatRule {
1230+
pub lo: f64,
1231+
pub hi: f64,
1232+
/// `unitsMultiplier`: applied on top of `scale` for this range only (ms → s past 1000).
1233+
pub mult: f64,
1234+
/// printf-ish template — `formatUnits` where the file has one, so the unit comes with it.
1235+
pub template: String,
1236+
}
1237+
1238+
impl NumFormat {
1239+
/// Render `raw` (the stored value) the way HX Edit would. `None` only if there are no rules.
1240+
pub fn display(&self, raw: f64) -> Option<String> {
1241+
let scaled = raw * self.scale;
1242+
let rule = self
1243+
.rules
1244+
.iter()
1245+
.find(|r| scaled >= r.lo && scaled < r.hi)
1246+
.or_else(|| self.rules.last())?;
1247+
Some(printf_f(&rule.template, scaled * rule.mult))
1248+
}
1249+
}
1250+
1251+
/// Substitute a float into a printf-ish template: `%[+][.N]f`, with `%%` for a literal percent.
1252+
/// Only these forms appear in the reference data; anything else is passed through untouched (some
1253+
/// templates are pure text, e.g. `blend`'s "Equal").
1254+
fn printf_f(template: &str, v: f64) -> String {
1255+
let mut out = String::with_capacity(template.len() + 8);
1256+
let b = template.as_bytes();
1257+
let mut i = 0;
1258+
let mut used = false;
1259+
while i < b.len() {
1260+
if b[i] != b'%' {
1261+
out.push(b[i] as char);
1262+
i += 1;
1263+
continue;
1264+
}
1265+
if i + 1 < b.len() && b[i + 1] == b'%' {
1266+
out.push('%');
1267+
i += 2;
1268+
continue;
1269+
}
1270+
// %[+][.N]f — anything that doesn't match is copied verbatim.
1271+
let mut j = i + 1;
1272+
let plus = j < b.len() && b[j] == b'+';
1273+
if plus {
1274+
j += 1;
1275+
}
1276+
let mut prec = 0usize;
1277+
if j < b.len() && b[j] == b'.' {
1278+
j += 1;
1279+
let s = j;
1280+
while j < b.len() && b[j].is_ascii_digit() {
1281+
j += 1;
1282+
}
1283+
prec = template[s..j].parse().unwrap_or(0);
1284+
}
1285+
if j < b.len() && b[j] == b'f' && !used {
1286+
if plus && v >= 0.0 {
1287+
out.push('+');
1288+
}
1289+
out.push_str(&format!("{v:.prec$}"));
1290+
used = true;
1291+
i = j + 1;
1292+
} else {
1293+
out.push('%');
1294+
i += 1;
1295+
}
1296+
}
1297+
out
1298+
}
1299+
1300+
/// Parse `HelixControls.json` into control name → [`NumFormat`] for the **continuous** controls
1301+
/// (the discrete ones are label lists — see [`discrete_control_labels`]). Resolves the `alias`
1302+
/// indirection the file uses for its 58 range-specialised aliases (`time_ms_20_1800` → `time_ms`).
1303+
fn numeric_control_formats(controls: &[u8]) -> std::collections::HashMap<String, NumFormat> {
1304+
let mut out = std::collections::HashMap::new();
1305+
let Ok(root) = serde_json::from_slice::<serde_json::Value>(controls) else {
1306+
return out;
1307+
};
1308+
let Some(obj) = root.as_object() else {
1309+
return out;
1310+
};
1311+
for name in obj.keys() {
1312+
// Follow `alias` hops, bounded so a cycle in the data can't hang the import.
1313+
let mut ctrl = &obj[name];
1314+
for _ in 0..8 {
1315+
match ctrl.get("alias").and_then(serde_json::Value::as_str) {
1316+
Some(target) => match obj.get(target) {
1317+
Some(next) => ctrl = next,
1318+
None => break,
1319+
},
1320+
None => break,
1321+
}
1322+
}
1323+
if ctrl.get("isDiscrete").and_then(serde_json::Value::as_bool) == Some(true) {
1324+
continue;
1325+
}
1326+
let scale = ctrl
1327+
.get("dspToDisplayScale")
1328+
.and_then(serde_json::Value::as_f64)
1329+
.unwrap_or(1.0);
1330+
let units = |v: &serde_json::Value| {
1331+
v.get("formatUnits")
1332+
.or_else(|| v.get("format"))
1333+
.and_then(serde_json::Value::as_str)
1334+
.map(str::to_string)
1335+
};
1336+
let mut rules = Vec::new();
1337+
match ctrl.get("format") {
1338+
Some(serde_json::Value::Array(arr)) => {
1339+
for e in arr {
1340+
let Some(t) = units(e) else { continue };
1341+
rules.push(FormatRule {
1342+
lo: e
1343+
.get("lowerBound")
1344+
.and_then(serde_json::Value::as_f64)
1345+
.unwrap_or(f64::NEG_INFINITY),
1346+
hi: e
1347+
.get("upperBound")
1348+
.and_then(serde_json::Value::as_f64)
1349+
.unwrap_or(f64::INFINITY),
1350+
mult: e
1351+
.get("unitsMultiplier")
1352+
.and_then(serde_json::Value::as_f64)
1353+
.unwrap_or(1.0),
1354+
template: t,
1355+
});
1356+
}
1357+
}
1358+
// A scalar `format`, or none at all but a bare `formatUnits`: one rule for everything.
1359+
_ => {
1360+
if let Some(t) = units(ctrl) {
1361+
rules.push(FormatRule {
1362+
lo: f64::NEG_INFINITY,
1363+
hi: f64::INFINITY,
1364+
mult: 1.0,
1365+
template: t,
1366+
});
1367+
}
1368+
}
1369+
}
1370+
if !rules.is_empty() {
1371+
out.insert(name.clone(), NumFormat { scale, rules });
1372+
}
1373+
}
1374+
out
1375+
}
1376+
11941377
/// Parse `HelixControls.json` into a map of **discrete** control name → ordered option labels (the
11951378
/// `format` string array of every entry marked `isDiscrete`). This is how enum params (the cab `Mic`,
11961379
/// reverb room sizes, etc.) get their dropdown labels: a param's `displayType` names its control
@@ -1421,6 +1604,44 @@ mod tests {
14211604
.expect("load reference data (run `fretwire import-data`)")
14221605
}
14231606

1607+
/// The case that sent the tester chasing a delay he thought was broken: stored `1.3728` is a
1608+
/// 1.4-second delay time, and the raw number said none of that. Also pins the two shapes the
1609+
/// recipe has to handle — a range switch with a `unitsMultiplier` (ms → s past 1000) and the
1610+
/// plain scaled percent.
1611+
#[test]
1612+
fn a_delay_time_reads_in_seconds_not_raw_dsp_units() {
1613+
let meta = dev_catalog().param_meta;
1614+
let delay = meta
1615+
.get("HD2_DelayAdriaticDelay")
1616+
.expect("bundled delay model present");
1617+
1618+
let time = delay.get("Time").expect("delay has a Time param");
1619+
let f = time.format.as_ref().expect("Time has a display format");
1620+
assert_eq!(f.display(1.3728).as_deref(), Some("1.373 s"));
1621+
// Under a second it stays in milliseconds, and under 10 ms it gains a decimal.
1622+
assert_eq!(f.display(0.4).as_deref(), Some("400 ms"));
1623+
assert_eq!(f.display(0.005).as_deref(), Some("5.0 ms"));
1624+
1625+
let mix = delay.get("Mix").expect("delay has a Mix param");
1626+
let f = mix.format.as_ref().expect("Mix has a display format");
1627+
assert_eq!(f.display(0.5).as_deref(), Some("50 %"));
1628+
1629+
// A switch is read as a label, so it must not carry a numeric recipe.
1630+
let mic = meta["HD2_CabMicIr_2x12JazzRivet"]["Mic"].clone();
1631+
assert!(mic.format.is_none());
1632+
}
1633+
1634+
#[test]
1635+
fn a_volume_param_keeps_its_sign_and_unit() {
1636+
let meta = dev_catalog().param_meta;
1637+
let lvl = meta["HD2_DelayAdriaticDelay"]["Level"]
1638+
.format
1639+
.clone()
1640+
.expect("Level has a display format");
1641+
assert_eq!(lvl.display(0.0).as_deref(), Some("+0.0 dB"));
1642+
assert_eq!(lvl.display(-4.89).as_deref(), Some("-4.9 dB"));
1643+
}
1644+
14241645
#[test]
14251646
fn cab_mic_param_is_a_12_option_enum() {
14261647
let meta = dev_catalog().param_meta;

crates/fretwire-tauri/src/dto.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ pub struct ParamDto {
103103
/// `false` when op 30 cannot address this param at all (see [`EditorParam::settable`]) — show
104104
/// the value, but no control.
105105
pub settable: bool,
106+
/// How to render this value with its unit. Sent as rules rather than a finished string because
107+
/// the panel re-formats continuously while a slider is dragged, before any value reaches Rust.
108+
pub format: Option<NumFormatDto>,
106109
}
107110

108111
#[derive(Serialize)]
@@ -111,6 +114,24 @@ pub struct SegStopDto {
111114
pub label: String,
112115
}
113116

117+
/// Display recipe for a continuous param — see [`fretwire_core::editor::NumFormat`]. The panel
118+
/// applies `scale`, picks the first rule bracketing the result, multiplies by its `mult`, and fills
119+
/// the printf-ish `template`.
120+
#[derive(Serialize)]
121+
pub struct NumFormatDto {
122+
pub scale: f64,
123+
pub rules: Vec<FormatRuleDto>,
124+
}
125+
126+
#[derive(Serialize)]
127+
pub struct FormatRuleDto {
128+
/// `null` for an unbounded end — JSON has no infinity.
129+
pub lo: Option<f64>,
130+
pub hi: Option<f64>,
131+
pub mult: f64,
132+
pub template: String,
133+
}
134+
114135
impl From<&EditorParam> for ParamDto {
115136
fn from(p: &EditorParam) -> Self {
116137
ParamDto {
@@ -133,6 +154,19 @@ impl From<&EditorParam> for ParamDto {
133154
})
134155
.collect(),
135156
settable: p.settable,
157+
format: p.meta.format.as_ref().map(|f| NumFormatDto {
158+
scale: f.scale,
159+
rules: f
160+
.rules
161+
.iter()
162+
.map(|r| FormatRuleDto {
163+
lo: fin_opt(Some(r.lo)),
164+
hi: fin_opt(Some(r.hi)),
165+
mult: r.mult,
166+
template: r.template.clone(),
167+
})
168+
.collect(),
169+
}),
136170
}
137171
}
138172
}

0 commit comments

Comments
 (0)