Skip to main content

max / audiofiles

17.2 KB · 451 lines History Blame Raw
1 //! The Sample Forge, described: three things you can make out of one sample.
2 //!
3 //! The fourteenth port and one of the two smallest, which is why it is worth
4 //! being clear about what it is not. `ui/forge_panel.rs` is 346 lines and the
5 //! description is not much shorter, because almost none of those lines are
6 //! layout: the window is three sections of live controls over one sample, and
7 //! the port is a straight reading of what each control asks.
8 //!
9 //! # One shape, not several, and it is the first screen here that is
10 //!
11 //! Every flow ported before this answers a different screen per state.
12 //! [`export`](super::export) has five, [`importing`](super::importing) nine,
13 //! [`edit`](super::edit) two. This has one: the shipped window keeps drawing
14 //! every control while a chop or a conform is in flight and greys them, because
15 //! the sample is still the subject and nothing has been arrived at. So `busy` is
16 //! a field on [`Forging`](super::Forging) rather than a shape, and it is what
17 //! deadens the controls -- as far as the vocabulary lets it, which turns out to
18 //! be the acts and not the fields. See the finding below.
19 //!
20 //! That is the rule stated from the other side for once, and it is worth having
21 //! both halves written down: **a state a reader arrived at is a shape, and a
22 //! property of the subject is a field.** "An export is running" is the first;
23 //! "this sample is busy" is the second.
24 //!
25 //! # What the description deletes: the fourth piggyback
26 //!
27 //! `state.forge.conform_device` is an `Option<String>` written back out of the
28 //! draw every frame, with the empty string meaning nothing chosen and a comment
29 //! saying so. It is a buffer for a picker, which is [`bulk`](super::bulk)'s
30 //! eleven `BulkModal` fields and [`edit`](super::edit)'s twelve knobs for the
31 //! fourth time: what is being chosen in a described screen is the runtime's, and
32 //! it arrives with the act that used it.
33 //!
34 //! `slice_marks` is **not** in that class and stays. It is the result of work the
35 //! app did, not a control's buffer, and the description carries what the controls
36 //! need of it — how many slices a preview found — as
37 //! [`Forging::slices`](super::Forging::slices).
38 //!
39 //! # THE FINDING: a field cannot be disabled at all
40 //!
41 //! [`Act`](quasi_router::Act) carries a
42 //! [`State`](quasi_router::layout::State) and
43 //! [`Act::disabled`](quasi_router::Act::disabled) sets it.
44 //! [`Field`](quasi_router::Field) carries no such member: its fourteen fields
45 //! are kind, name, label, hint, error, placeholder, options, required,
46 //! max_length, min, max, step, extended and width, and none of them is "not
47 //! answering right now".
48 //!
49 //! Every control in this window is greyed while a chop or a conform runs, which
50 //! is nine `add_enabled(!disabled, ..)` calls in the shipped file. The acts can
51 //! say it and **the sensitivity slider, the BPM dial, the device picker and the
52 //! trim threshold cannot**, so a described forge mid-run offers four live-looking
53 //! controls whose writes the routes then have to refuse.
54 //!
55 //! This is sharper than the neighbouring gap rather than the same one:
56 //! `quasi:vocabulary:disabled-reason` is a control that says it is dead without
57 //! saying why, and this is a control with no way to say it is dead. Filed as
58 //! `quasi:vocabulary:field-state`, four consumers in this one window. The
59 //! degradation is the routes, which refuse the write and are what the reader
60 //! would have been stopped from making.
61 //!
62 //! # A second consumer for `quasi:vocabulary:disabled-reason`
63 //!
64 //! Chop is disabled until a preview has run, and the shipped button explains
65 //! itself in an `on_disabled_hover_text`: "Preview the slices first to see how
66 //! many will be created." That is the gap [`importing`](super::importing) filed
67 //! this pass with four consumers of its own —
68 //! [`Act::disabled`](quasi_router::Act::disabled) carries no reason where
69 //! [`Choice::unless`](quasi_router::Choice::unless) does — and this is the fifth.
70 //! Degraded the same way: the sentence is a line of its own beside the control.
71 //!
72 //! The count on the label survives, and it is the better half of that button
73 //! anyway. "Chop into 14 slices" says the blast radius before the press, which
74 //! is the correction the shipped screen made to itself (AF-9) and the same one
75 //! the review screen's "Apply 3 Tags" is.
76 //!
77 //! # What is deliberately not described
78 //!
79 //! - **The waveform and its slice markers.** [`edit`](super::edit)'s exclusion,
80 //! unchanged and for its reason: a rendered picture of samples, with lines
81 //! painted over it at pixel positions derived from fractions. Domain
82 //! rendering. What the description keeps is the number of slices, which is the
83 //! only thing any control here reads off it.
84 //! - **The plugin-host foreshadow.** "Plugin processing (CLAP/VST): coming soon"
85 //! is marketing copy for something that does not exist, and a description of a
86 //! screen should not carry a description of a screen that has not been built.
87 //! The shipped window may keep it; there is nothing to port.
88
89 use quasi_router::layout::{Selector, Tone};
90 use quasi_router::{
91 Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
92 Slot,
93 };
94
95 use super::{Chop, DeviceChoice, Forging, Knob, Panels};
96
97 /// The region the window answers into.
98 const BODY: &str = "forge-body";
99
100 /// The name the device picker submits under.
101 const DEVICE: &str = "device";
102
103 /// Register the forge's routes.
104 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
105 router
106 .get("/forge", index)
107 .post("/forge/slice/{how}", slice_by)
108 .post("/forge/set/{knob}", turn)
109 .post("/forge/preview", preview)
110 .post("/forge/chop", chop)
111 .post("/forge/device", choose_device)
112 .post("/forge/conform", conform)
113 .post("/forge/trim", trim_silence)
114 }
115
116 /// `GET /forge`
117 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
118 Ok(screen(state).into())
119 }
120
121 /// `POST /forge/slice/{how}`
122 fn slice_by(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
123 let name = request.captures.require("how")?;
124 let how = Chop::from_key(name).ok_or_else(|| RouteError::not_found("no such chop method"))?;
125 state.forge.slice_by(how);
126 Ok(screen(state).into())
127 }
128
129 /// `POST /forge/set/{knob}`
130 ///
131 /// One route for five controls across two sections, which is [`export`]'s
132 /// arrangement and [`Knob`](super::Knob) is what closes the set.
133 ///
134 /// [`export`]: super::export
135 fn turn(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
136 let name = request.captures.require("knob")?;
137 let knob = Knob::from_key(name).ok_or_else(|| RouteError::not_found("no such control"))?;
138 let value = request
139 .payload
140 .get(name)
141 .or_else(|| request.payload.get(Node::SELECTED))
142 .unwrap_or_default();
143 state.forge.turn(knob, value);
144 Ok(screen(state).into())
145 }
146
147 /// `POST /forge/preview`
148 fn preview(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
149 forging(state)?;
150 state.forge.preview();
151 Ok(screen(state).into())
152 }
153
154 /// `POST /forge/chop`
155 ///
156 /// Refused without a preview, which is what the shipped button is disabled on
157 /// and for the reason it was made to be (AF-9): committing to an unknown slice
158 /// count is the thing the preview exists to stop. Changing any chop parameter
159 /// clears the marks, so the gate re-arms itself.
160 fn chop(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
161 let forging = forging(state)?;
162 if forging.slices == 0 {
163 return Err(RouteError::not_found("preview the slices first"));
164 }
165 state.forge.chop();
166 Ok(screen(state).into())
167 }
168
169 /// `POST /forge/device`
170 ///
171 /// An empty value is "nothing chosen" rather than a device named the empty
172 /// string, which is the reading the shipped write-back makes. A name no profile
173 /// carries is a refusal: the address is reachable by typing.
174 fn choose_device(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
175 let forging = forging(state)?;
176 let chosen = request
177 .payload
178 .get(DEVICE)
179 .or_else(|| request.payload.get(Node::SELECTED))
180 .unwrap_or_default();
181 if !chosen.is_empty() && !forging.devices.iter().any(|device| device.name == chosen) {
182 return Err(RouteError::not_found("no such device profile"));
183 }
184 state.forge.choose_device(chosen);
185 Ok(screen(state).into())
186 }
187
188 /// `POST /forge/conform`
189 fn conform(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
190 let forging = forging(state)?;
191 if forging.device.is_none() {
192 return Err(RouteError::not_found("choose a device first"));
193 }
194 state.forge.conform();
195 Ok(screen(state).into())
196 }
197
198 /// `POST /forge/trim`
199 ///
200 /// Refused under two samples, which is what the shipped section is hidden
201 /// behind: trimming a batch of one is the single-sample operation wearing the
202 /// batch's label.
203 fn trim_silence(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
204 let forging = forging(state)?;
205 if forging.chosen < 2 {
206 return Err(RouteError::not_found("choose two or more samples"));
207 }
208 state.forge.trim_silence();
209 Ok(screen(state).into())
210 }
211
212 /// The sample in the forge, refusing every write when there is none.
213 fn forging(state: &Panels<'_>) -> Result<Forging, RouteError> {
214 state
215 .forge
216 .forging()
217 .ok_or_else(|| RouteError::not_found("no sample is in the forge"))
218 }
219
220 /// The window.
221 fn screen(state: &Panels<'_>) -> Screen {
222 let body = match state.forge.forging() {
223 Some(forging) => loaded(&forging),
224 None => Slot::new(BODY, RegionKind::Pane).with(Node::empty(
225 "Select a sample and open the forge to chop, conform, or batch-process it.",
226 )),
227 };
228 Screen::sidebar_content("Sample Forge").with(body)
229 }
230
231 /// A sample, and the three things that can be made out of it.
232 fn loaded(forging: &Forging) -> Slot {
233 let mut body = Slot::new(BODY, RegionKind::Pane)
234 .with(Node::page(forging.name.clone()))
235 .with(Node::text(format!("{} Hz", forging.rate)));
236
237 // Said rather than drawn as a spinner beside a separator: what a reader
238 // needs from it is that every control below is currently inert, and the
239 // controls say that themselves through `State::Disabled`.
240 if forging.busy {
241 body = body.with(Node::banner(Tone::Info, "Working..."));
242 }
243
244 body = body.with(Node::Region(chopping(forging)));
245 body = body.with(Node::Region(conforming(forging)));
246 body.with(Node::Region(batching(forging)))
247 }
248
249 /// Slicing one sample into several.
250 fn chopping(forging: &Forging) -> Slot {
251 let mut group = Slot::new("forge-chop", RegionKind::Group)
252 .with(Node::section("Chop"))
253 .with(Node::Select {
254 kind: Selector::Segmented,
255 options: Chop::ALL
256 .into_iter()
257 .map(|how| (Choice::new(how.as_str(), how.label()), None))
258 .collect(),
259 chosen: Some(forging.how.as_str().to_owned()),
260 action: Some(Action::post(format!(
261 "/forge/slice/{}",
262 forging.how.as_str()
263 ))),
264 });
265
266 // Only the parameters the chosen method reads, which is the shipped
267 // window's own `match` and the settings screen's line: a control that
268 // cannot be used is worse than one that is not there.
269 group = match forging.how {
270 Chop::Transient => group.with(dial(
271 forging,
272 Field::range(Knob::Sensitivity.as_str(), "Sensitivity", "0", "1")
273 .step("0.01")
274 .value(format!("{:.2}", forging.sensitivity)),
275 )),
276 Chop::Equal => group.with(strip(
277 forging,
278 Knob::Divisions,
279 &forging.divisions.to_string(),
280 [2_usize, 4, 8, 16, 32].map(|n| (n.to_string(), n.to_string())),
281 )),
282 Chop::Bpm => group
283 .with(dial(
284 forging,
285 Field::range(Knob::Bpm.as_str(), "BPM", "20", "300")
286 .step("0.5")
287 .value(format!("{:.1}", forging.bpm)),
288 ))
289 .with(strip(
290 forging,
291 Knob::Subdivisions,
292 &forging.subdivisions.to_string(),
293 [("1", "1/4"), ("2", "1/8"), ("4", "1/16")]
294 .map(|(value, label)| (value.to_owned(), label.to_owned())),
295 )),
296 };
297
298 group = group.with(Node::Act(live(
299 forging,
300 Act::new("Preview slices", Action::post("/forge/preview")),
301 )));
302
303 // The count on the label, which is the correction the shipped button made to
304 // itself: a commit says its blast radius before it is pressed.
305 let mut go = Act::new(
306 if forging.slices == 0 {
307 "Chop".to_owned()
308 } else {
309 format!(
310 "Chop into {} slice{}",
311 forging.slices,
312 if forging.slices == 1 { "" } else { "s" }
313 )
314 },
315 Action::post("/forge/chop"),
316 );
317 if forging.slices == 0 {
318 // The fifth consumer of `quasi:vocabulary:disabled-reason`: the shipped
319 // button says this to a pointer and nothing else can.
320 group = group.with(Node::text(
321 "Preview the slices first to see how many will be created.",
322 ));
323 go = go.disabled();
324 } else {
325 go = live(forging, go);
326 }
327
328 group.with(Node::Act(go)).with(Node::text(
329 "Slices are written into a new folder beside this sample.",
330 ))
331 }
332
333 /// Making one sample fit a piece of hardware.
334 fn conforming(forging: &Forging) -> Slot {
335 let group = Slot::new("forge-conform", RegionKind::Group);
336
337 if forging.devices.is_empty() {
338 return group
339 .with(Node::section("Conform for device"))
340 .with(Node::empty("No device profiles available."));
341 }
342
343 let mut field = Field::select(
344 DEVICE,
345 "Conform for device",
346 forging
347 .devices
348 .iter()
349 .map(|device| Choice::new(device.name.clone(), describe(device)))
350 .collect(),
351 )
352 .changes(Action::post("/forge/device"));
353 // The instruction is the picker's ghost text rather than a disabled button's
354 // job, which is the call the shipped screen already made: a select with
355 // nothing chosen reads as an empty box, and the greyed control beside it is
356 // the wrong place to explain that.
357 field.placeholder = Some("Select device...".to_owned());
358 field.value = Some(forging.device.clone().unwrap_or_default());
359
360 let mut go = Act::new("Conform", Action::post("/forge/conform"));
361 if forging.device.is_none() {
362 go = go.disabled();
363 } else {
364 go = live(forging, go);
365 }
366
367 group
368 .with(Node::Field(Box::new(field)))
369 .with(Node::Act(go))
370 .with(Node::text(
371 "Resamples and converts bit depth to match the device, as a new sample.",
372 ))
373 }
374
375 /// The one operation here that is about the selection rather than the sample.
376 fn batching(forging: &Forging) -> Slot {
377 let group = Slot::new("forge-batch", RegionKind::Group).with(Node::section("Batch"));
378
379 if forging.chosen < 2 {
380 return group.with(Node::empty("Select 2+ samples to batch trim silence."));
381 }
382
383 group
384 .with(Node::Field(Box::new(
385 Field::range(Knob::Threshold.as_str(), "Threshold", "-96", "-20")
386 .unit("dBFS")
387 .step("1")
388 .value(format!("{:.0}", forging.threshold_db))
389 .changes(writes(Knob::Threshold)),
390 )))
391 .with(Node::Act(live(
392 forging,
393 Act::new(
394 format!("Trim silence on {} samples", forging.chosen),
395 Action::post("/forge/trim"),
396 ),
397 )))
398 }
399
400 /// What a device profile says about itself, as one option.
401 fn describe(device: &DeviceChoice) -> String {
402 if device.summary.is_empty() {
403 device.name.clone()
404 } else {
405 format!("{} ({})", device.name, device.summary)
406 }
407 }
408
409 /// A number the slicing reads, live unless a run is in flight.
410 fn dial(forging: &Forging, field: Field) -> Node {
411 let _ = forging;
412 let name = field.name.clone();
413 Node::Field(Box::new(
414 field.changes(Action::post(format!("/forge/set/{name}"))),
415 ))
416 }
417
418 /// A handful of values that do not fold away.
419 ///
420 /// `Selector::Segmented` rather than a `Field`, which is the line `settings.rs`
421 /// drew and the shipped window agrees with: five slice counts and three
422 /// subdivisions are drawn as rows of selectable buttons, and naming "exactly one
423 /// of these few" is describing the choice rather than choosing the widget.
424 fn strip(
425 forging: &Forging,
426 knob: Knob,
427 chosen: &str,
428 options: impl IntoIterator<Item = (String, String)>,
429 ) -> Node {
430 let _ = forging;
431 Node::Select {
432 kind: Selector::Segmented,
433 options: options
434 .into_iter()
435 .map(|(value, label)| (Choice::new(value, label), None))
436 .collect(),
437 chosen: Some(chosen.to_owned()),
438 action: Some(writes(knob)),
439 }
440 }
441
442 /// The address a control changing this number calls.
443 fn writes(knob: Knob) -> Action {
444 Action::post(format!("/forge/set/{}", knob.as_str()))
445 }
446
447 /// The same control, dead while a run is in flight.
448 fn live(forging: &Forging, act: Act) -> Act {
449 if forging.busy { act.disabled() } else { act }
450 }
451