Skip to main content

max / audiofiles

24.4 KB · 651 lines History Blame Raw
1 //! The export flow, described rather than built.
2 //!
3 //! The fourth port, and the first that is a **flow** rather than a screen. The
4 //! three before it each answered one address for as long as they were open;
5 //! this one has four screens and the user does not choose between them. Which
6 //! one is showing is a fact about the app — is anything being written, has it
7 //! finished — and the description says so by answering a different screen from
8 //! the same address.
9 //!
10 //! # The finding this port made, and it changed `quasi`
11 //!
12 //! **A described screen could not say that the thing it is about had moved.**
13 //! A route answers a screen built from the state at the moment it was asked,
14 //! and the runtime keeps that answer until the user fires something. That is
15 //! right for a settings panel, where nothing changes unless the user changes
16 //! it. It is wrong for every screen here:
17 //!
18 //! - The progress screen changes with **no user input at all**. Files are being
19 //! written by a worker; the count moves on its own.
20 //! - The configure screen changes on input the description *did* carry, but a
21 //! frame late: a write here is an [`Intent`](super::Intent) the host applies
22 //! after the frame, so the answer built in the same frame is built from state
23 //! the write has not reached yet. This is not new with this port —
24 //! `files.rs`'s sort caret had it — but here every single control has it, so
25 //! it stopped being survivable.
26 //!
27 //! `quasi` 0.12.0's `Runtime::reload` is the answer, and the shape of the answer
28 //! is the part worth keeping: it is a **host** call, not a description member.
29 //! Nothing in a `Screen` says how often it goes stale, because how often a fact
30 //! moves is a property of the app holding it rather than of the screen showing
31 //! it. The host knows it started an export. The description does not, and should
32 //! not have to.
33 //!
34 //! # Why a meter, when `Meter`'s own documentation says not for this
35 //!
36 //! [`Meter`](quasi_router::Meter) says it is "a proportion of a set and not the
37 //! progress of an operation", on the grounds that an operation is live and a
38 //! screen is described once per answer. Files-written of files-to-write **is** a
39 //! proportion of a set; what made it look like an operation was the second half
40 //! of that sentence, and `reload` is what stops it being true. Each answer still
41 //! describes a static fact, and the host asks again. The refusal was right for
42 //! its reason and the reason has moved, which is worth recording as a change to
43 //! the premise rather than as an exception being taken.
44 //!
45 //! # What is not describable, and it is three things
46 //!
47 //! | The shipped screen does | Described | Why not |
48 //! |---|---|---|
49 //! | AIFF 4 GB chunk warning | yes | arithmetic over the items and the settings |
50 //! | device file-size warning | yes | the same, against the profile's limit |
51 //! | naming-pattern live preview | yes | `RenamePattern` resolved against the first item, and pure |
52 //! | **disk space warning** | no | `statvfs` on the destination: a fact about this host's filesystem |
53 //! | **"Browse..." for the destination** | no | a native folder dialog |
54 //! | **the token chips** | no | see below |
55 //!
56 //! The destination picker is the **third consumer** of a finding both prior
57 //! ports filed: *a control that asks the host where to put something and then
58 //! acts has no vocabulary.* `FieldKind::File` covers picking a file to submit;
59 //! nothing covers opening a save dialog and writing there. goingson's settings
60 //! port found it, audiofiles' settings port confirmed it at Export Theme, and
61 //! this is the third. Under the evidence rule three consumers is not drift.
62 //!
63 //! The token chips are a **new** gap and a smaller one: nine buttons that each
64 //! append their own text to the field beside them. Nothing in the vocabulary
65 //! says "put this text into that field" — an `Act` calls a route, and routing a
66 //! keystroke through a handler to change a buffer the renderer owns is the wrong
67 //! shape at every layer. Recorded, not papered over: the described screen names
68 //! the tokens in the field's hint, which keeps the fact and loses the affordance.
69
70 use quasi_router::layout::{FieldKind, Selector, Tone};
71 use quasi_router::{
72 Act, Action, Choice, Field, Node, Outcome, RegionKind, Request, Response, RouteError, Router,
73 Screen, Slot,
74 };
75
76 use super::{Channels, Format, Panels, Phase, ProfileChoice, Setting, Settings, Subject};
77
78 /// The region the whole flow answers into.
79 ///
80 /// One region for four screens, because they are four answers to one address
81 /// rather than four places. Nothing navigates between them and the back button
82 /// has nowhere to go, which is the truth: the user cannot walk back into
83 /// configuring an export that is already running.
84 const BODY: &str = "export-body";
85
86 /// The largest an AIFF chunk may be, with headroom for the headers.
87 ///
88 /// The shipped screen's number, kept because the warning is the same warning.
89 /// Ninety per cent of `u32::MAX` leaves room for chunk headers and rounding.
90 const AIFF_SAFE_BYTES: f64 = u32::MAX as f64 * 0.9;
91
92 /// Worst-case bytes per second, for the device size check.
93 ///
94 /// Stereo 24-bit at 48 kHz, which is what the shipped screen assumes and for the
95 /// same reason: the check is a warning, and a warning that under-estimates is
96 /// worse than one that over-estimates.
97 const WORST_CASE_BYTES_PER_SEC: f64 = 288_000.0;
98
99 /// Register this flow's routes.
100 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
101 router
102 .get("/export", index)
103 .post("/export/begin", begin)
104 .post("/export/set/{setting}", configure)
105 .post("/export/start", start)
106 .post("/export/cancel", cancel)
107 .post("/export/dismiss", dismiss)
108 }
109
110 /// `GET /export`
111 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
112 Ok(screen(state).into())
113 }
114
115 /// `POST /export/begin`
116 ///
117 /// The toolbar's Export button, which the toolbar port left out because it
118 /// "belongs with the import flow, which is its own remaining pass". It is the
119 /// door rather than a stage: the flow's four screens were described first and
120 /// had no way in, so pressing Export was the one thing about exporting that the
121 /// description could not say.
122 ///
123 /// It answers the flow rather than the screen it was pressed on, because the
124 /// flow takes over the pane in the shipped app too. The answer is built before
125 /// the intent lands, so the first frame reads `Idle` and the reload corrects it
126 /// — the standing cost this module's header is about.
127 fn begin(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
128 state.export.open();
129 Ok(Response::from(Outcome::Goto(Action::get("/export"))))
130 }
131
132 /// `POST /export/set/{setting}`
133 ///
134 /// One route for every control on the configure screen, which is `settings.rs`'s
135 /// arrangement and works here for the same reason: [`Setting`] closes the set,
136 /// so the route carries no second list of what it will name.
137 ///
138 /// The answer is built **before** the change lands, and that is not a bug in
139 /// this route. The write is an intent the host applies after the frame; the
140 /// host then reloads. See the module header.
141 fn configure(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
142 let name = request.captures.require("setting")?;
143 let setting =
144 Setting::from_key(name).ok_or_else(|| RouteError::not_found("no such export setting"))?;
145 let value = request.payload.get(name).unwrap_or_default();
146 state.export.configure(setting, value);
147 Ok(screen(state).into())
148 }
149
150 /// `POST /export/start`
151 fn start(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
152 state.export.start();
153 Ok(screen(state).into())
154 }
155
156 /// `POST /export/cancel`
157 fn cancel(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
158 state.export.cancel();
159 Ok(screen(state).into())
160 }
161
162 /// `POST /export/dismiss`
163 fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
164 state.export.dismiss();
165 Ok(screen(state).into())
166 }
167
168 /// Whichever of the four screens the flow is on.
169 fn screen(state: &Panels<'_>) -> Screen {
170 let body = match state.export.phase() {
171 Phase::Idle => idle(),
172 Phase::Configuring {
173 subjects,
174 profiles,
175 settings,
176 } => configuring(&subjects, &profiles, &settings),
177 Phase::Running {
178 done,
179 total,
180 current,
181 } => running(done, total, &current),
182 Phase::Finished {
183 total,
184 errors,
185 destination,
186 } => finished(total, &errors, destination.as_deref()),
187 Phase::Cancelled {
188 done,
189 total,
190 destination,
191 } => cancelled(done, total, destination.as_deref()),
192 };
193 Screen::sidebar_content("Export").with(body)
194 }
195
196 /// Nothing to export.
197 ///
198 /// A stand-in rather than an empty pane, and with no way out offered: the export
199 /// flow is entered by selecting samples in the file list, which is a different
200 /// screen. Offering a control here would be inventing an affordance the shipped
201 /// app does not have.
202 fn idle() -> Slot {
203 Slot::new(BODY, RegionKind::Pane).with(Node::empty(
204 "Nothing is being exported. Select samples and choose Export to start.",
205 ))
206 }
207
208 /// Choosing what and where.
209 fn configuring(subjects: &[Subject], profiles: &[ProfileChoice], settings: &Settings) -> Slot {
210 let mut body = Slot::new(BODY, RegionKind::Pane)
211 .with(Node::page("Export Samples"))
212 .with(Node::text(subject_count(subjects.len(), profiles.len())));
213
214 for warning in warnings(subjects, profiles, settings) {
215 body = body.with(warning);
216 }
217
218 if !profiles.is_empty() {
219 body = body
220 .with(Node::section("Device Profile"))
221 .with(profile_field(profiles, settings.device_profile.as_deref()));
222 // What the lock is hiding, said rather than implied. The shipped screen
223 // puts four muted lines under the picker; each is a fact about the
224 // device, so each is prose.
225 if let Some(chosen) = chosen_profile(profiles, settings.device_profile.as_deref()) {
226 body = body.with(Node::text(describe(chosen)));
227 }
228 }
229
230 // A profile locks the audio settings, so the description stops naming them:
231 // a control that cannot be used is worse than one that is not there, and the
232 // shipped screen agrees -- it hides the whole block behind `!has_profile`.
233 if settings.device_profile.is_none() {
234 body = body
235 .with(Node::section("Format"))
236 .with(format_field(settings.format));
237 if settings.format != Format::Original {
238 body = body.with(Node::banner(
239 Tone::Warning,
240 "Re-encoding strips embedded metadata chunks (BWF, iXML, loop points, \
241 cue markers, ID3). Choose Original to preserve them.",
242 ));
243 body = body
244 .with(Node::section("Sample Rate"))
245 .with(sample_rate_field(settings.sample_rate))
246 .with(Node::section("Bit Depth"))
247 .with(bit_depth_field(settings.bit_depth));
248 }
249 body = body
250 .with(Node::section("Channels"))
251 .with(channels_field(settings.channels));
252 }
253
254 body = body
255 .with(Node::section("Structure"))
256 .with(structure_field(settings.flatten))
257 .with(Node::Field(Box::new(
258 Field::new(
259 FieldKind::Checkbox,
260 Setting::Sidecar.as_str(),
261 "Include metadata (.audiofiles.json)",
262 )
263 .value(if settings.sidecar { "on" } else { "" })
264 .changes(writes(Setting::Sidecar)),
265 )));
266
267 if settings.flatten {
268 body = body
269 .with(Node::section("Naming Pattern"))
270 .with(naming_field(settings.naming_pattern.as_deref()));
271 if let Some(preview) = preview(settings.naming_pattern.as_deref(), subjects.first()) {
272 body = body.with(preview);
273 }
274 }
275
276 // Read-only, and the module header says why: naming where files go means
277 // opening a native folder dialog, which no description reaches.
278 body = body
279 .with(Node::section("Destination"))
280 .with(Node::text(settings.destination.clone()));
281
282 body.with(Node::Act(Act::new("Export", Action::post("/export/start"))))
283 .with(Node::Act(Act::new(
284 "Cancel",
285 Action::post("/export/dismiss"),
286 )))
287 }
288
289 /// Files being written.
290 fn running(done: usize, total: usize, current: &str) -> Slot {
291 let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Exporting"));
292
293 // Zero is "the worker has not counted them yet" rather than an empty export,
294 // and a meter of 0/0 would draw as finished. Pending is the honest reading
295 // and it is what `Readiness` is for.
296 body = if total == 0 {
297 body.with(Node::StandIn {
298 state: quasi_router::layout::Readiness::Pending,
299 message: "Starting export...".to_owned(),
300 act: None,
301 })
302 } else {
303 body.with(Node::Meter(
304 quasi_router::Meter::new(clamp(done), clamp(total)).label("samples"),
305 ))
306 };
307
308 if !current.is_empty() {
309 body = body.with(Node::text(format!("Exporting: {current}")));
310 }
311
312 body.with(Node::Act(Act::new(
313 "Cancel",
314 Action::post("/export/cancel"),
315 )))
316 }
317
318 /// Finished, however it went.
319 fn finished(total: usize, errors: &[(String, String)], destination: Option<&str>) -> Slot {
320 let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Export Complete"));
321
322 body = if errors.is_empty() {
323 body.with(Node::text(format!("Successfully exported {total} files.")))
324 } else {
325 let listed = body.with(Node::banner(
326 Tone::Danger,
327 format!("Exported {total} files with {} errors.", errors.len()),
328 ));
329 // One list of every failure rather than a list per row: the set is the
330 // thing being reported, and a run of one-row lists would say each error
331 // is its own collection.
332 listed.with(Node::list(errors.iter().map(|(name, error)| {
333 quasi_router::Row::new(name.clone()).secondary(quasi_router::Prose::Text(error.clone()))
334 })))
335 };
336
337 body = body.with(Node::Act(Act::new("Done", Action::post("/export/dismiss"))));
338 match destination {
339 Some(path) => body.with(Node::Act(Act::new(
340 "Open destination folder",
341 Action::external(path),
342 ))),
343 None => body,
344 }
345 }
346
347 /// Given up on partway.
348 fn cancelled(done: usize, total: usize, destination: Option<&str>) -> Slot {
349 let mut body = Slot::new(BODY, RegionKind::Pane)
350 .with(Node::page("Export Cancelled"))
351 .with(Node::text(format!(
352 "{done} of {total} samples were written before this stopped."
353 )));
354
355 if let Some(path) = destination {
356 body = body
357 .with(Node::text(format!(
358 "The files already written are in {path}."
359 )))
360 .with(Node::Act(Act::new(
361 "Open destination folder",
362 Action::external(path),
363 )));
364 }
365
366 body.with(Node::Act(Act::new("Done", Action::post("/export/dismiss"))))
367 }
368
369 /// What is about to be exported, and what is available to do it with.
370 fn subject_count(subjects: usize, profiles: usize) -> String {
371 let head = format!("{subjects} samples to export");
372 if profiles == 0 {
373 head
374 } else {
375 format!("{head}. {profiles} device profiles available.")
376 }
377 }
378
379 /// Everything worth warning about before anything is written.
380 ///
381 /// Two of the shipped screen's three, and the third is named in the module
382 /// header. Both of these are arithmetic over facts the description already
383 /// carries, which is what makes them describable at all.
384 fn warnings(subjects: &[Subject], profiles: &[ProfileChoice], settings: &Settings) -> Vec<Node> {
385 let mut said = Vec::new();
386
387 if settings.format == Format::Aiff {
388 let longest = subjects
389 .iter()
390 .filter_map(|subject| subject.duration)
391 .fold(0.0_f64, f64::max);
392 let safe = AIFF_SAFE_BYTES / bytes_per_sec(settings).max(1.0);
393 if longest > safe {
394 said.push(Node::banner(
395 Tone::Warning,
396 format!(
397 "AIFF chunks cap at 4 GB. At the current rate, depth and channels, \
398 samples longer than about {:.0} min may fail to export.",
399 safe / 60.0
400 ),
401 ));
402 }
403 }
404
405 if let Some(profile) = chosen_profile(profiles, settings.device_profile.as_deref())
406 && let Some(cap) = profile.max_file_size_bytes
407 {
408 let over: Vec<&str> = subjects
409 .iter()
410 .filter(|subject| {
411 subject
412 .duration
413 .is_some_and(|seconds| (seconds * WORST_CASE_BYTES_PER_SEC) as u64 > cap)
414 })
415 .map(|subject| subject.name.as_str())
416 .collect();
417 let megabytes = cap as f64 / 1_048_576.0;
418 match over.as_slice() {
419 [] => {}
420 [only] => said.push(Node::banner(
421 Tone::Danger,
422 format!("\"{only}\" may exceed the device file size limit ({megabytes:.0} MB)."),
423 )),
424 many => said.push(Node::banner(
425 Tone::Danger,
426 format!(
427 "{} samples may exceed the device file size limit ({megabytes:.0} MB).",
428 many.len()
429 ),
430 )),
431 }
432 }
433
434 said
435 }
436
437 /// What one second of audio costs at these settings.
438 ///
439 /// The shipped screen's `bytes_per_sec`, against the described settings rather
440 /// than the config. Defaults bias high — an absent rate or depth is the largest
441 /// each may be — because this feeds a warning and a warning that under-estimates
442 /// is the one that does harm.
443 fn bytes_per_sec(settings: &Settings) -> f64 {
444 let rate = f64::from(settings.sample_rate.unwrap_or(48_000));
445 let depth = f64::from(settings.bit_depth.unwrap_or(24))
446 .div_euclid(8.0)
447 .max(1.0);
448 let channels = match settings.channels {
449 Channels::Mono => 1.0,
450 Channels::Stereo | Channels::Original => 2.0,
451 };
452 rate * depth * channels
453 }
454
455 /// The profile in force, if one is.
456 fn chosen_profile<'a>(
457 profiles: &'a [ProfileChoice],
458 chosen: Option<&str>,
459 ) -> Option<&'a ProfileChoice> {
460 let name = chosen?;
461 profiles.iter().find(|profile| profile.name == name)
462 }
463
464 /// What a device profile says about itself, as one line.
465 ///
466 /// Joined rather than four nodes, because the four facts are one statement about
467 /// one device and the shipped screen's four muted labels are a layout choice.
468 fn describe(profile: &ProfileChoice) -> String {
469 let mut said = vec![format!("by {}", profile.manufacturer)];
470 said.extend(profile.summary.clone());
471 said.extend(profile.category.clone());
472 said.extend(profile.notes.clone());
473 said.join(". ")
474 }
475
476 /// The device profile picker.
477 ///
478 /// A `Field` rather than a `Node::Select` for `settings.rs`'s reason: the choice
479 /// count is open — profiles are plugins — so it has to be able to fold away, and
480 /// that is a dropdown.
481 fn profile_field(profiles: &[ProfileChoice], chosen: Option<&str>) -> Node {
482 let mut options = vec![Choice::new(String::new(), "None (manual)")];
483 options.extend(profiles.iter().map(|profile| {
484 Choice::new(
485 profile.name.clone(),
486 format!("{} ({})", profile.name, profile.manufacturer),
487 )
488 }));
489
490 let mut field = Field::select(Setting::DeviceProfile.as_str(), "Device profile", options)
491 .changes(writes(Setting::DeviceProfile));
492 field.value = Some(chosen.unwrap_or_default().to_owned());
493 Node::Field(Box::new(field))
494 }
495
496 /// What to write.
497 fn format_field(format: Format) -> Node {
498 let chosen = match format {
499 Format::Original => "original",
500 Format::Wav => "wav",
501 Format::Aiff => "aiff",
502 };
503 picker(
504 Setting::Format,
505 chosen,
506 [
507 ("original", "Original (copy as-is)"),
508 ("wav", "WAV (decode and re-encode)"),
509 ("aiff", "AIFF (decode and re-encode)"),
510 ],
511 )
512 }
513
514 /// The sample rate to write at.
515 fn sample_rate_field(rate: Option<u32>) -> Node {
516 let chosen = rate.map_or_else(String::new, |rate| rate.to_string());
517 picker(
518 Setting::SampleRate,
519 &chosen,
520 [
521 ("", "Original"),
522 ("44100", "44,100 Hz"),
523 ("48000", "48,000 Hz"),
524 ("96000", "96,000 Hz"),
525 ],
526 )
527 }
528
529 /// The bit depth to write at.
530 fn bit_depth_field(depth: Option<u16>) -> Node {
531 let chosen = depth.map_or_else(String::new, |depth| depth.to_string());
532 picker(
533 Setting::BitDepth,
534 &chosen,
535 [("", "Original"), ("16", "16-bit"), ("24", "24-bit")],
536 )
537 }
538
539 /// The channel layout to write.
540 fn channels_field(channels: Channels) -> Node {
541 let chosen = match channels {
542 Channels::Original => "original",
543 Channels::Mono => "mono",
544 Channels::Stereo => "stereo",
545 };
546 picker(
547 Setting::Channels,
548 chosen,
549 [
550 ("original", "Original"),
551 ("mono", "Mono"),
552 ("stereo", "Stereo"),
553 ],
554 )
555 }
556
557 /// Whether the tree survives the export.
558 fn structure_field(flatten: bool) -> Node {
559 picker(
560 Setting::Flatten,
561 if flatten { "on" } else { "" },
562 [
563 ("", "Preserve tree"),
564 ("on", "Flatten (all files in one folder)"),
565 ],
566 )
567 }
568
569 /// One of a handful of choices, drawn as a strip.
570 ///
571 /// `Selector::Segmented` and not a `Field`, which is the line `settings.rs`
572 /// drew: a handful of options that do not fold away is a strip, and the shipped
573 /// screen draws every one of these as a column of radios. Naming the widget
574 /// would be the description choosing a control; naming "exactly one of these
575 /// few" is describing the choice.
576 fn picker<'a>(
577 setting: Setting,
578 chosen: &str,
579 options: impl IntoIterator<Item = (&'a str, &'a str)>,
580 ) -> Node {
581 Node::Select {
582 kind: Selector::Segmented,
583 options: options
584 .into_iter()
585 .map(|(value, label)| (Choice::new(value, label), None))
586 .collect(),
587 chosen: Some(chosen.to_owned()),
588 action: Some(writes(setting)),
589 }
590 }
591
592 /// How to name the output files.
593 ///
594 /// The hint carries the tokens, which is the token-chip finding in its degraded
595 /// form: the nine names survive and the click-to-append does not.
596 fn naming_field(pattern: Option<&str>) -> Node {
597 Node::Field(Box::new(
598 Field::new(
599 FieldKind::Text,
600 Setting::NamingPattern.as_str(),
601 "Naming pattern",
602 )
603 .value(pattern.unwrap_or_default())
604 .hint("Tokens: {name} {bpm} {key} {class} {duration} {n} {nn} {nnn} {ext}")
605 .changes(writes(Setting::NamingPattern)),
606 ))
607 }
608
609 /// What the first file would be called.
610 ///
611 /// Describable because it is pure: parsing a pattern and resolving it against
612 /// one item's own fields reaches nothing outside the description. A parse
613 /// failure is the point rather than an error to swallow — it is what catches a
614 /// typo before two hundred files are written under it.
615 fn preview(pattern: Option<&str>, first: Option<&Subject>) -> Option<Node> {
616 let pattern = pattern.filter(|pattern| !pattern.is_empty())?;
617 match audiofiles_core::rename::RenamePattern::parse(pattern) {
618 Ok(parsed) => {
619 let first = first?;
620 let stem = parsed.resolve(&audiofiles_core::rename::RenameContext {
621 name: first.name.clone(),
622 extension: first.ext.clone(),
623 bpm: first.bpm,
624 musical_key: first.musical_key.clone(),
625 duration: first.duration,
626 index: 0,
627 });
628 let named = if first.ext.is_empty() {
629 stem
630 } else {
631 format!("{stem}.{}", first.ext)
632 };
633 Some(Node::text(format!("Preview: {named}")))
634 }
635 Err(error) => Some(Node::banner(Tone::Warning, format!("Pattern: {error}"))),
636 }
637 }
638
639 /// The address a control changing this setting calls.
640 fn writes(setting: Setting) -> Action {
641 Action::post(format!("/export/set/{}", setting.as_str()))
642 }
643
644 /// A count as the meter carries one.
645 ///
646 /// Saturating rather than `as`, because a truncating cast on a count that came
647 /// from a worker is the kind of arithmetic that reads as fine and is not.
648 fn clamp(count: usize) -> u32 {
649 u32::try_from(count).unwrap_or(u32::MAX)
650 }
651