@@ -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 ;
0 commit comments