Skip to main content

max / audiofiles

30.2 KB · 809 lines History Blame Raw
1 //! The sample editor, described: seven operations on one sample, and three on
2 //! everything chosen.
3 //!
4 //! The thirteenth audiofiles port, and the widest single screen the app has:
5 //! `ui/edit_panel.rs` is 760 lines and dispatches nine distinct operations from
6 //! one window.
7 //!
8 //! # What the description deletes: eleven knobs on `EditUiState`
9 //!
10 //! `trim_start`, `trim_end`, `gain_db`, `norm_peak`, `norm_target`, `fade_in`,
11 //! `fade_duration_ms`, `fade_curve`, `silence_position_ms`,
12 //! `silence_duration_ms`, `remove_start_ms`, `remove_end_ms`. Twelve, counting
13 //! properly. Every one is a buffer for a control that is being adjusted, read
14 //! only by the `apply_*` function the button beside it calls, and
15 //! [`bulk`](super::bulk) already made this deletion once for `BulkModal`: what
16 //! is being typed into a described screen is the runtime's, and it arrives with
17 //! the submit that used it.
18 //!
19 //! What is left on the app's side is the *sample* — the hash, whether an edit is
20 //! running, what a finished one is waiting for — which is the app's state and
21 //! not a control's.
22 //!
23 //! It also fixes something on the way. The batch section's three buttons
24 //! **piggyback on the single-sample sliders**: `batch_normalize_peak` takes
25 //! `state.edit.norm_target`, `batch_gain` takes `state.edit.gain_db`. That was
26 //! caught once already (M-14: "removes the silent-piggyback footgun where the
27 //! user couldn't tell what value the batch button would use") and answered by
28 //! baking the number into the button's label. Here the batch operations are
29 //! forms with their own fields, so there is nothing to piggyback on and nothing
30 //! to bake.
31 //!
32 //! # One address, two shapes, and why this one is not the overlay finding
33 //!
34 //! `state.edit.result_prompt` makes the shipped panel draw a different body:
35 //! "How should the edited sample be handled?", with the editor's own controls
36 //! gone. That is a state the user *arrived* at rather than a place they went, so
37 //! it is a shape at `/edit` — the rule `sync`, `export` and `detail` settled.
38 //!
39 //! Worth saying because the pass before this filed
40 //! `quasi:vocabulary:unprompted-overlay` for exactly the shape this looks like.
41 //! It is not the same: the shipped app draws the prompt *in place*, not over
42 //! what the user was doing, so nothing here is trying to raise itself.
43 //!
44 //! # THE FINDING: an undo offer cannot outlive the answer that raised it
45 //!
46 //! `Response::undoable` is the vocabulary's shape for "that happened, take it
47 //! back", and its header is right that the timeout is renderer policy. It hangs
48 //! off a `Response`'s notice, which means the offer exists only in the answer
49 //! that made it.
50 //!
51 //! An audiofiles edit finishes on a worker thread. The answer that dispatched it
52 //! was built and shown seconds earlier, and there is no answer being made at the
53 //! moment the result lands — so the one place `undoable` can be attached is the
54 //! one place nothing knows an undo is available yet. `Screen::notices` is a
55 //! `Vec<Node>`, and `Node::Notice` carries no action, so a screen cannot say it
56 //! either.
57 //!
58 //! What this port does instead is describe the standing affordance the shipped
59 //! panel draws: the last edit's name, and an `Act` beside it. That works and it
60 //! is not the same claim — an act is a control the user finds, and an undo offer
61 //! is a consequence the app volunteers.
62 //!
63 //! Filed as `quasi:vocabulary:undo-outlives-the-answer`.
64 //!
65 //! # A third consumer for `5672cad4`, the valid answer that costs something
66 //!
67 //! "Peak: -2.0 dB -> 1.5 dB (clips!)" is not an error — the gain is a legal
68 //! value and the edit will run — and it is not standing help either, because it
69 //! depends on what has been typed. makeover-layout `5672cad4` is the gap that a
70 //! `Field`'s two message slots are help and error with nothing between, and this
71 //! is its third measured site after the export screen's re-encoding warning.
72 //!
73 //! The workaround is the rename preview's: `Field::changes` answers a fragment
74 //! into a region beside the field. It costs a route and a region for what wants
75 //! to be a member.
76 //!
77 //! # A second consumer for `91114ff1`, the interval
78 //!
79 //! Trim is one question with two values that constrain each other: start must be
80 //! before end, and the shipped panel enforces it by writing one of them
81 //! (`if trim_start >= trim_end { trim_start = trim_end - 0.001 }`) every frame.
82 //! `FieldKind::Range` describes one value in an extent, so the description here
83 //! is two ranges that do not know about each other and a route that refuses the
84 //! inverted pair. Same gap audiofiles' filter panel is waiting on, from a second
85 //! app surface.
86 //!
87 //! # What is deliberately not described
88 //!
89 //! - **The waveform**, its trim wash, its draggable handles and click-to-seek.
90 //! Domain rendering: a click that maps a pixel to a frame and writes into a
91 //! mutex an audio thread is filling is a host fact, not a fact about a sample.
92 //! The numeric path *is* described, which is what the shipped panel calls the
93 //! sliders beside it.
94 //! - **The in-progress greying.** Every section disables itself while an edit
95 //! runs, deliberately keeping the layout rather than collapsing to a spinner.
96 //! Described as `Act::disabled` on what an edit would start, which says the
97 //! same thing without naming a colour.
98
99 use quasi_router::layout::{FieldKind, Notice, Tone};
100 use quasi_router::{
101 Act, Action, Choice, Field, Figure, Node, RegionKind, Request, Response, RouteError, Router,
102 Screen, Slot,
103 };
104
105 use audiofiles_core::edit::FadeCurve;
106
107 use super::{Editing, Panels};
108 use crate::state::EditResultMode;
109
110 /// The region the editor answers into.
111 const BODY: &str = "edit-body";
112
113 /// The region the clipping warning lands in.
114 const CLIPPING: &str = "edit-clipping";
115
116 /// The names a control submits under.
117 const START: &str = "start";
118 /// See [`START`].
119 const END: &str = "end";
120 /// See [`START`].
121 const GAIN: &str = "gain";
122 /// See [`START`].
123 const MODE: &str = "mode";
124 /// See [`START`].
125 const TARGET: &str = "target";
126 /// See [`START`].
127 const CURVE: &str = "curve";
128 /// See [`START`].
129 const LENGTH: &str = "length";
130 /// See [`START`].
131 const AT: &str = "at";
132 /// See [`START`].
133 const FROM: &str = "from";
134 /// See [`START`].
135 const TO: &str = "to";
136 /// See [`START`].
137 const RESULT: &str = "result";
138 /// See [`START`].
139 const REMEMBER: &str = "remember";
140
141 /// The value the peak/loudness choice submits for peak.
142 const PEAK: &str = "peak";
143
144 /// The value it submits for loudness.
145 const LUFS: &str = "lufs";
146
147 /// Where the fade direction's two answers are written.
148 const FADE_IN: &str = "in";
149
150 /// Register the editor's routes.
151 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
152 router
153 .get("/edit", screen)
154 .post("/edit/trim", trim)
155 .post("/edit/gain", gain)
156 .post("/edit/gain/preview", clipping)
157 .post("/edit/normalize", normalize)
158 .post("/edit/reverse", reverse)
159 .post("/edit/fade", fade)
160 .post("/edit/silence/insert", insert_silence)
161 .post("/edit/silence/remove", remove_range)
162 .post("/edit/play", play)
163 .post("/edit/stop", stop)
164 .post("/edit/cancel", cancel)
165 .post("/edit/undo", undo)
166 .post("/edit/result", remember)
167 .post("/edit/result/choose", choose)
168 .post("/edit/result/discard", discard)
169 .post("/edit/batch/normalize", batch_normalize)
170 .post("/edit/batch/gain", batch_gain)
171 .post("/edit/batch/reverse", batch_reverse)
172 }
173
174 /// `GET /edit`
175 fn screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
176 Ok(editor(state)?.into())
177 }
178
179 /// The whole editor, at whichever of its two shapes it is in.
180 fn editor(state: &Panels<'_>) -> Result<Screen, RouteError> {
181 let sample = subject(state)?;
182 let body = if sample.asking {
183 asking(&sample)
184 } else {
185 editing(&sample)
186 };
187 Ok(Screen::sidebar_content("Sample Editor").with(body))
188 }
189
190 /// What is being edited, or a refusal.
191 ///
192 /// Nothing is a `NotFound` rather than an empty editor: the shipped window is
193 /// only open because something is being edited, and a described screen for "no
194 /// sample" would be a screen the app does not have.
195 fn subject(state: &Panels<'_>) -> Result<Editing, RouteError> {
196 state
197 .editor
198 .subject()
199 .ok_or_else(|| RouteError::not_found("nothing is being edited"))
200 }
201
202 /// The question a finished edit is waiting on.
203 ///
204 /// A form rather than the shipped three buttons, and the reason is a gap:
205 /// "Replace Original" and "Create Sibling" each need to carry the answer to
206 /// "Remember my choice", and an `Act` cannot carry a value another control is
207 /// holding. That is makeover-layout `28a777df` (a control carries an action but
208 /// no computed payload), and this is a consumer of it. Discard stays an act
209 /// because it carries nothing.
210 fn asking(sample: &Editing) -> Slot {
211 Slot::new(BODY, RegionKind::Pane)
212 .with(Node::page("Edit Result"))
213 .with(Node::text("How should the edited sample be handled?"))
214 .with(Node::Form {
215 fields: vec![
216 Field::radio(RESULT, "Result", result_modes()).value(
217 sample
218 .result
219 .clone()
220 .unwrap_or_else(|| EditResultMode::Sibling.as_value().to_owned()),
221 ),
222 // The shipped checkbox is ticked when a standing answer exists,
223 // which is the same fact this reads.
224 Field::new(FieldKind::Checkbox, REMEMBER, "Remember my choice")
225 .value(if sample.result.is_some() { "on" } else { "" }),
226 ],
227 submit: "Use this".to_owned(),
228 action: Action::post("/edit/result/choose"),
229 })
230 .with(Node::Act(
231 Act::new("Discard edit", Action::post("/edit/result/discard"))
232 .tone(Tone::Danger)
233 .confirm("Throw this edit away?"),
234 ))
235 }
236
237 /// The editor proper.
238 fn editing(sample: &Editing) -> Slot {
239 let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page(sample.name.clone()));
240
241 // What the shipped info line says, as facts rather than one muted string.
242 body = body.with(Node::Figure(Figure::new(
243 sample.sample_rate.to_string(),
244 "Hz",
245 )));
246 if let Some(duration) = sample.duration {
247 body = body.with(Node::Figure(Figure::new(
248 format!("{duration:.3}"),
249 "seconds",
250 )));
251 }
252 if let Some(peak) = sample.peak_db {
253 body = body.with(Node::Figure(Figure::new(format!("{peak:.1}"), "dBFS")));
254 }
255
256 if sample.working {
257 body = body
258 .with(Node::Notice {
259 kind: Notice::Banner,
260 tone: Tone::Info,
261 text: "Applying edit...".to_owned(),
262 })
263 .with(Node::Act(Act::new("Cancel", Action::post("/edit/cancel"))));
264 }
265
266 body = transport(body, sample);
267 body = trim_section(body);
268 body = levels(body, sample);
269 body = transform(body, sample);
270 body = silence(body, sample);
271 body = result(body, sample);
272 batch(body, sample)
273 }
274
275 /// Play, pause and stop, independent of the main list's selection.
276 fn transport(body: Slot, sample: &Editing) -> Slot {
277 body.with(Node::Act(
278 Act::new(
279 if sample.playing { "Pause" } else { "Play" },
280 Action::post("/edit/play"),
281 )
282 .key("space"),
283 ))
284 .with(Node::Act(Act::new("Stop", Action::post("/edit/stop"))))
285 }
286
287 /// The span to keep.
288 fn trim_section(body: Slot) -> Slot {
289 body.with(Node::section("Trim")).with(Node::Form {
290 // Two ranges over a fraction of the sample, which is what the shipped
291 // sliders are. The seconds the shipped panel prints beside each is the
292 // same number in the sample's units, and a description that carried both
293 // would be describing a label. See the module header on the interval
294 // gap: these two constrain each other and cannot say so.
295 fields: vec![span(START, "Start"), span(END, "End").value("1")],
296 submit: "Trim".to_owned(),
297 action: Action::post("/edit/trim"),
298 })
299 }
300
301 /// One end of the trim, as a fraction of the whole.
302 fn span(name: &str, label: &str) -> Field {
303 Field::range(name, label, "0", "1").step("0.001").value("0")
304 }
305
306 /// Gain and normalise.
307 fn levels(body: Slot, sample: &Editing) -> Slot {
308 let mut body = body.with(Node::section("Levels"));
309
310 body = body
311 .with(Node::Form {
312 fields: vec![
313 Field::range(GAIN, "Gain", "-24", "24")
314 .step("0.1")
315 .value("0")
316 // The clipping consequence, which the field cannot carry
317 // itself. See the module header, `5672cad4`.
318 .changes(Action::post("/edit/gain/preview")),
319 ],
320 submit: "Apply gain".to_owned(),
321 action: Action::post("/edit/gain"),
322 })
323 .with(Node::Region(
324 Slot::new(CLIPPING, RegionKind::Group).with(clips(sample.peak_db, 0.0)),
325 ));
326
327 // Peak and LUFS have different ranges (-24..0 dBFS against -24..-6 LUFS) and
328 // different defaults, and the shipped panel resets the target when the mode
329 // changes because "the carried-over value is meaningless across modes". Both
330 // facts are about the pair rather than about either control, and neither is
331 // sayable: the widest range is described and the route refuses what falls
332 // outside the chosen mode's half.
333 body.with(Node::Form {
334 fields: vec![
335 Field::radio(
336 MODE,
337 "Normalize by",
338 vec![
339 Choice::new(PEAK, "Peak"),
340 Choice::new(LUFS, "Loudness (LUFS)"),
341 ],
342 )
343 .value(PEAK),
344 Field::range(TARGET, "Target", "-24", "0")
345 .step("0.1")
346 .value("-1"),
347 ],
348 submit: "Normalize".to_owned(),
349 action: Action::post("/edit/normalize"),
350 })
351 }
352
353 /// What the gain about to be applied would do to the peak.
354 ///
355 /// Its own node so `Field::changes` can answer it as a fragment while the
356 /// control is being moved, which is the rename preview's arrangement.
357 fn clips(peak: Option<f64>, gain: f64) -> Node {
358 let Some(peak) = peak else {
359 return Node::empty("Peak unknown until this sample is analysed.");
360 };
361 let predicted = peak + gain;
362 if predicted <= 0.0 {
363 return Node::text(format!("Peak: {peak:.1} dB -> {predicted:.1} dB"));
364 }
365 Node::Notice {
366 kind: Notice::Banner,
367 tone: Tone::Danger,
368 text: format!("Peak: {peak:.1} dB -> {predicted:.1} dB (clips!)"),
369 }
370 }
371
372 /// Reverse and fade.
373 fn transform(body: Slot, sample: &Editing) -> Slot {
374 body.with(Node::section("Transform"))
375 .with(Node::Act(disable_while(
376 Act::new("Reverse", Action::post("/edit/reverse")),
377 sample,
378 )))
379 .with(Node::Form {
380 // The fade row was left out of the forms conversion as "a slider, a
381 // chooser and an Apply composing one operation", which was the right
382 // call about a *field* and is what a `Form` is for: several answers
383 // and one submit that uses them together.
384 fields: vec![
385 Field::radio(
386 FADE_IN,
387 "Fade",
388 vec![Choice::new("in", "In"), Choice::new("out", "Out")],
389 )
390 .value("in"),
391 Field::range(LENGTH, "Length", "10", "10000")
392 .step("10")
393 .value("100"),
394 Field::select(
395 CURVE,
396 "Curve",
397 FadeCurve::all()
398 .into_iter()
399 .map(|curve| Choice::new(curve.as_value(), curve.label()))
400 .collect(),
401 )
402 .value(FadeCurve::Linear.as_value()),
403 ],
404 submit: "Apply fade".to_owned(),
405 action: Action::post("/edit/fade"),
406 })
407 }
408
409 /// Insert and remove.
410 fn silence(body: Slot, sample: &Editing) -> Slot {
411 // The shipped drag values clamp to the sample's length where analysis has
412 // said what it is. A described `max` says the same thing, and where the
413 // length is unknown there is nothing to say rather than a made-up ceiling.
414 let cap = sample.duration.map(|seconds| seconds * 1000.0);
415
416 body.with(Node::section("Silence"))
417 .with(Node::Form {
418 fields: vec![
419 milliseconds(AT, "Insert at", cap).value("0"),
420 milliseconds(LENGTH, "Duration", Some(60_000.0)).value("100"),
421 ],
422 submit: "Insert".to_owned(),
423 action: Action::post("/edit/silence/insert"),
424 })
425 .with(Node::Form {
426 fields: vec![
427 milliseconds(FROM, "Remove from", cap).value("0"),
428 milliseconds(TO, "to", cap).value("0"),
429 ],
430 submit: "Remove".to_owned(),
431 action: Action::post("/edit/silence/remove"),
432 })
433 }
434
435 /// A number of milliseconds, bounded where the app knows the bound.
436 fn milliseconds(name: &str, label: &str, cap: Option<f64>) -> Field {
437 // `min` and `max` are fields rather than builders on this type, where
438 // `step` is a builder. Set directly, the way the settings screen sets a
439 // value.
440 let mut field = Field::new(FieldKind::Number, name, label).step("10");
441 field.min = Some("0".to_owned());
442 field.max = cap.map(|cap| format!("{cap:.0}"));
443 field
444 }
445
446 /// What happens to the edited sample, and what happened to the last one.
447 fn result(body: Slot, sample: &Editing) -> Slot {
448 let mut field =
449 Field::radio(RESULT, "Result", result_modes()).changes(Action::post("/edit/result"));
450 if let Some(chosen) = &sample.result {
451 field = field.value(chosen.clone());
452 }
453
454 let mut body = body
455 .with(Node::section("Result"))
456 .with(Node::Field(Box::new(field)));
457
458 if sample.result.as_deref() == Some(EditResultMode::Replace.as_value()) {
459 body = body.with(Node::Notice {
460 kind: Notice::Banner,
461 tone: Tone::Warning,
462 text: "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or Undo below to revert.".to_owned(),
463 });
464 }
465
466 // The standing undo. See the module header: this is an act rather than
467 // `Response::undoable`, and the ten-second timeout the shipped panel keeps
468 // (with an `egui::Id` round trip and a `request_repaint_after` to land it)
469 // is renderer policy that no longer has anywhere to be written down.
470 if let Some(last) = &sample.undoing {
471 body = body
472 .with(Node::text(format!("Last edit: {last}")))
473 .with(Node::Act(Act::new("Undo", Action::post("/edit/undo"))));
474 }
475 body
476 }
477
478 /// The two answers to "Result".
479 fn result_modes() -> Vec<Choice> {
480 vec![
481 Choice::new(EditResultMode::Replace.as_value(), "Replace original"),
482 Choice::new(EditResultMode::Sibling.as_value(), "Create sibling"),
483 ]
484 }
485
486 /// Everything chosen at once.
487 fn batch(body: Slot, sample: &Editing) -> Slot {
488 if sample.chosen < 2 {
489 return body;
490 }
491 let chosen = sample.chosen;
492
493 body.with(Node::section(format!("Batch: {chosen} samples")))
494 .with(Node::text("Applies to every chosen sample at once."))
495 .with(Node::Form {
496 // Its own value rather than the single-sample slider's. See the
497 // module header on the piggyback this deletes.
498 fields: vec![
499 Field::radio(
500 MODE,
501 "Normalize by",
502 vec![
503 Choice::new(PEAK, "Peak"),
504 Choice::new(LUFS, "Loudness (LUFS)"),
505 ],
506 )
507 .value(PEAK),
508 Field::range(TARGET, "Target", "-24", "0")
509 .step("0.1")
510 .value("-1"),
511 ],
512 submit: format!("Normalize {chosen} samples"),
513 action: Action::post("/edit/batch/normalize"),
514 })
515 .with(Node::Form {
516 fields: vec![
517 Field::range(GAIN, "Gain", "-24", "24")
518 .step("0.1")
519 .value("0"),
520 ],
521 submit: format!("Apply gain to {chosen} samples"),
522 action: Action::post("/edit/batch/gain"),
523 })
524 .with(Node::Act(
525 // The shipped panel asks before reversing more than ten, through
526 // `ConfirmAction::ReverseSamples` and the 140-line match behind it.
527 // `Act::confirm` is the whole of that here -- the fourth variant
528 // this port has replaced with a builder method.
529 reverse_batch(chosen),
530 ))
531 }
532
533 /// Reversing everything chosen, asking first where there is enough to regret.
534 fn reverse_batch(chosen: usize) -> Act {
535 let act = Act::new(
536 format!("Reverse {chosen} samples"),
537 Action::post("/edit/batch/reverse"),
538 );
539 if chosen > REGRET {
540 act.confirm(format!("Reverse {chosen} samples?"))
541 } else {
542 act
543 }
544 }
545
546 /// How many samples make a batch reverse worth asking about.
547 ///
548 /// The shipped threshold, kept with its reasoning: single-sample Reverse is its
549 /// own undo (click it again), and on a large selection that trick requires
550 /// remembering it ran at all.
551 const REGRET: usize = 10;
552
553 /// Held back while an edit is running, which is the shipped disabled flag.
554 fn disable_while(act: Act, sample: &Editing) -> Act {
555 if sample.working { act.disabled() } else { act }
556 }
557
558 /// `POST /edit/trim`
559 fn trim(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
560 let sample = subject(state)?;
561 let start = fraction(&request, START)?;
562 let end = fraction(&request, END)?;
563 // The pair the description cannot state. The shipped panel keeps it true by
564 // writing one of the two every frame; here it is a refusal, which is the
565 // honest form of the same rule for an address reachable by typing.
566 if start >= end {
567 return Err(RouteError::not_found("start must be before end"));
568 }
569 state.editor.trim(start, end);
570 answered(state, format!("Trimming {}.", sample.name))
571 }
572
573 /// `POST /edit/gain`
574 fn gain(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
575 subject(state)?;
576 let db = decimal(&request, GAIN)?;
577 state.editor.gain(db);
578 answered(state, format!("Applying {db:.1} dB."))
579 }
580
581 /// `POST /edit/gain/preview`
582 ///
583 /// A fragment, so the warning changes while the control moves and the screen
584 /// under it stays where it was.
585 fn clipping(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
586 let sample = subject(state)?;
587 // A control mid-drag can send anything, including half a number, so an
588 // unreadable value is "no gain yet" rather than a refusal the user would see
589 // as an error.
590 let gain = request
591 .payload
592 .get(GAIN)
593 .and_then(|value| value.parse().ok())
594 .unwrap_or(0.0);
595 Ok(Response::fragment(CLIPPING, clips(sample.peak_db, gain)))
596 }
597
598 /// `POST /edit/normalize`
599 fn normalize(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
600 subject(state)?;
601 let (peak, target) = normalizing(&request)?;
602 state.editor.normalize(peak, target);
603 answered(state, "Normalizing.".to_owned())
604 }
605
606 /// `POST /edit/reverse`
607 fn reverse(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
608 subject(state)?;
609 state.editor.reverse();
610 answered(state, "Reversing.".to_owned())
611 }
612
613 /// `POST /edit/fade`
614 fn fade(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
615 subject(state)?;
616 let fading_in = request.payload.get(FADE_IN).unwrap_or("in") != "out";
617 let ms = decimal(&request, LENGTH)?;
618 let curve = request.payload.get(CURVE).unwrap_or_default();
619 // Refused rather than defaulted: a curve the app cannot read back is a fade
620 // the user did not ask for, and `FadeCurve::from_value` is the pairing that
621 // says which is which.
622 let curve =
623 FadeCurve::from_value(curve).ok_or_else(|| RouteError::not_found("no such fade curve"))?;
624 state.editor.fade(fading_in, ms, curve.as_value());
625 answered(
626 state,
627 format!(
628 "Fading {} over {ms:.0} ms.",
629 if fading_in { "in" } else { "out" }
630 ),
631 )
632 }
633
634 /// `POST /edit/silence/insert`
635 fn insert_silence(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
636 subject(state)?;
637 let at = decimal(&request, AT)?;
638 let ms = decimal(&request, LENGTH)?;
639 if ms <= 0.0 {
640 return Err(RouteError::not_found("silence has to be longer than that"));
641 }
642 state.editor.insert_silence(at, ms);
643 answered(state, format!("Inserting {ms:.0} ms."))
644 }
645
646 /// `POST /edit/silence/remove`
647 fn remove_range(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
648 subject(state)?;
649 let from = decimal(&request, FROM)?;
650 let to = decimal(&request, TO)?;
651 if from >= to {
652 return Err(RouteError::not_found(
653 "the span has to start before it ends",
654 ));
655 }
656 state.editor.remove_range(from, to);
657 answered(state, format!("Removing {:.0} ms.", to - from))
658 }
659
660 /// `POST /edit/play`
661 fn play(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
662 subject(state)?;
663 state.editor.play();
664 Ok(editor(state)?.into())
665 }
666
667 /// `POST /edit/stop`
668 fn stop(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
669 subject(state)?;
670 state.editor.stop();
671 Ok(editor(state)?.into())
672 }
673
674 /// `POST /edit/cancel`
675 fn cancel(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
676 subject(state)?;
677 state.editor.cancel();
678 answered(state, "Edit cancelled.".to_owned())
679 }
680
681 /// `POST /edit/undo`
682 fn undo(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
683 let sample = subject(state)?;
684 // Refused where there is nothing to take back, which the standing act is an
685 // affordance for rather than a guarantee.
686 let last = sample
687 .undoing
688 .ok_or_else(|| RouteError::not_found("there is nothing to undo"))?;
689 state.editor.undo();
690 answered(state, format!("Undoing {last}."))
691 }
692
693 /// `POST /edit/result`
694 fn remember(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
695 subject(state)?;
696 let mode = mode_named(&request)?;
697 state.editor.remember(mode.as_value());
698 Ok(editor(state)?.into())
699 }
700
701 /// `POST /edit/result/choose`
702 fn choose(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
703 subject(state)?;
704 let mode = mode_named(&request)?;
705 let remember = request.payload.get(REMEMBER).unwrap_or_default() == "on";
706 state.editor.choose(mode.as_value(), remember);
707 answered(state, "Edit applied.".to_owned())
708 }
709
710 /// `POST /edit/result/discard`
711 fn discard(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
712 subject(state)?;
713 state.editor.discard();
714 answered(state, "Edit result discarded.".to_owned())
715 }
716
717 /// `POST /edit/batch/normalize`
718 fn batch_normalize(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
719 let chosen = batched(state)?;
720 let (peak, target) = normalizing(&request)?;
721 state.editor.batch_normalize(peak, target);
722 answered(state, format!("Normalizing {chosen} samples."))
723 }
724
725 /// `POST /edit/batch/gain`
726 fn batch_gain(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
727 let chosen = batched(state)?;
728 let db = decimal(&request, GAIN)?;
729 state.editor.batch_gain(db);
730 answered(state, format!("Applying {db:.1} dB to {chosen} samples."))
731 }
732
733 /// `POST /edit/batch/reverse`
734 fn batch_reverse(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
735 let chosen = batched(state)?;
736 state.editor.batch_reverse();
737 answered(state, format!("Reversing {chosen} samples."))
738 }
739
740 /// How many samples a batch operation would touch, refusing where it is not a
741 /// batch.
742 ///
743 /// The section is hidden below two, and the address is reachable regardless.
744 fn batched(state: &Panels<'_>) -> Result<usize, RouteError> {
745 let chosen = subject(state)?.chosen;
746 if chosen < 2 {
747 return Err(RouteError::not_found("choose more than one sample first"));
748 }
749 Ok(chosen)
750 }
751
752 /// The normalise mode and target, as a pair, since neither is readable alone.
753 ///
754 /// The bounds differ by mode -- peak runs to 0 dBFS and loudness stops at -6
755 /// LUFS -- and the description carries the wider of the two, so this is where
756 /// the narrower one is enforced.
757 fn normalizing(request: &Request) -> Result<(bool, f64), RouteError> {
758 let peak = request.payload.get(MODE).unwrap_or(PEAK) != LUFS;
759 let target = decimal(request, TARGET)?;
760 let allowed = if peak { -24.0..=0.0 } else { -24.0..=-6.0 };
761 if !allowed.contains(&target) {
762 return Err(RouteError::not_found(if peak {
763 "a peak target runs from -24 to 0 dBFS"
764 } else {
765 "a loudness target runs from -24 to -6 LUFS"
766 }));
767 }
768 Ok((peak, target))
769 }
770
771 /// The result mode a request names.
772 fn mode_named(request: &Request) -> Result<EditResultMode, RouteError> {
773 EditResultMode::from_value(request.payload.get(RESULT).unwrap_or_default())
774 .ok_or_else(|| RouteError::not_found("no such result mode"))
775 }
776
777 /// A fraction of the sample's length, as a control submits one.
778 fn fraction(request: &Request, name: &str) -> Result<f32, RouteError> {
779 let value: f32 = request
780 .payload
781 .get(name)
782 .unwrap_or_default()
783 .parse()
784 .map_err(|_| RouteError::not_found("that is not a position"))?;
785 if !(0.0..=1.0).contains(&value) {
786 return Err(RouteError::not_found("a position runs from 0 to 1"));
787 }
788 Ok(value)
789 }
790
791 /// A number a control submitted.
792 fn decimal(request: &Request, name: &str) -> Result<f64, RouteError> {
793 request
794 .payload
795 .get(name)
796 .unwrap_or_default()
797 .parse()
798 .map_err(|_| RouteError::not_found("that is not a number"))
799 }
800
801 /// The editor again, saying what was just asked for.
802 ///
803 /// Every operation answers the whole screen rather than going anywhere: an edit
804 /// is something done *to* what is on screen, and the shipped panel stays open
805 /// through all of them.
806 fn answered(state: &Panels<'_>, say: String) -> Result<Response, RouteError> {
807 Ok(Response::from(editor(state)?).toast(Tone::Success, say))
808 }
809