diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 0224b1d..73011fa 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use rfd::FileDialog; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{json, Value}; use std::{ collections::HashMap, @@ -141,6 +141,49 @@ struct RehearsalRolePayload { simplification: String, setup_note: String, manual_overrides: Vec, + overlap_warnings: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SectionTimeRangePayload { + start: u32, + end: u32, +} + +impl<'de> Deserialize<'de> for SectionTimeRangePayload { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct RawSectionTimeRangePayload { + start: u32, + end: u32, + } + + let raw = RawSectionTimeRangePayload::deserialize(deserializer)?; + if raw.end <= raw.start { + return Err(serde::de::Error::custom( + "section timeRange end must be greater than start", + )); + } + + Ok(Self { + start: raw.start, + end: raw.end, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct PartGraphNodePayload { + role_id: String, + is_active: bool, + handoff_to: Vec, + handoff_from: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -149,8 +192,10 @@ struct RehearsalSectionPayload { id: String, label: String, groove: String, + time_range: SectionTimeRangePayload, confidence: ConfidencePayload, roles: Vec, + part_graph: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -353,6 +398,74 @@ fn normalize_local_audio_source(path: &Path) -> Result Result { + let filepath = metadata + .get("filepath") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Failed to parse YouTube import response.".to_string())?; + let title = metadata + .get("title") + .and_then(|value| value.as_str()) + .unwrap_or("Unknown YouTube Audio"); + let path = Path::new(filepath); + let link_metadata = std::fs::symlink_metadata(path) + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + if link_metadata.file_type().is_symlink() { + return Err("YouTube import returned an invalid audio path.".to_string()); + } + + let canonical_cache_root = cache_root + .canonicalize() + .map_err(|_| "Could not validate YouTube import workspace.".to_string())?; + let canonical = path + .canonicalize() + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + if !canonical.starts_with(&canonical_cache_root) { + return Err("YouTube import returned an invalid audio path.".to_string()); + } + + let file_metadata = std::fs::metadata(&canonical) + .map_err(|_| "Could not read downloaded audio file.".to_string())?; + if !file_metadata.is_file() || file_metadata.len() == 0 { + return Err("YouTube import returned an invalid audio file.".to_string()); + } + + let extension = canonical + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .ok_or_else(|| "YouTube import returned an unsupported audio format.".to_string())?; + if !AUDIO_EXTENSIONS.contains(&extension.as_str()) { + return Err("YouTube import returned an unsupported audio format.".to_string()); + } + + let safe_title: String = title + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '.' => '_', + c if c.is_control() => '_', + c => c, + }) + .take(100) + .collect(); + let safe_title = if safe_title.is_empty() { + "youtube_audio".to_string() + } else { + safe_title + }; + + Ok(LocalAudioSourcePayload { + source_path: canonical.to_string_lossy().into_owned(), + file_name: format!("{safe_title}.{extension}"), + extension, + file_size_bytes: file_metadata.len(), + }) +} + fn parse_request_payload(payload: Value) -> Result { let Value::Object(map) = payload else { return Err("Invalid analysis job request: invalid field 'root'".into()); @@ -787,44 +900,7 @@ async fn import_youtube_url( if parsed.get("ok").and_then(|v| v.as_bool()) == Some(true) { if let Some(metadata) = parsed.get("metadata") { - let filepath = metadata - .get("filepath") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let title = metadata - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown YouTube Audio"); - let path = Path::new(filepath); - let metadata_fs = std::fs::metadata(path) - .map_err(|_| "Could not read downloaded audio file.".to_string())?; - let extension = path - .extension() - .and_then(|v| v.to_str()) - .unwrap_or("m4a") - .to_string(); - - let safe_title: String = title - .chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '.' => '_', - c if c.is_control() => '_', - c => c, - }) - .take(100) - .collect(); - let safe_title = if safe_title.is_empty() { - "youtube_audio".to_string() - } else { - safe_title - }; - - let source = LocalAudioSourcePayload { - source_path: filepath.to_string(), - file_name: format!("{}.{}", safe_title, extension), - extension, - file_size_bytes: metadata_fs.len(), - }; + let source = youtube_source_from_metadata(metadata, &cache_root)?; let summary = ProjectBootstrapSummaryPayload { project_id, @@ -883,6 +959,30 @@ fn is_supported_youtube_url(url: &str) -> bool { false } + +fn project_payload_from_content(content: &str) -> Result { + if let Ok(parsed) = serde_json::from_str::(content) { + return Ok(parsed); + } + + let payload = serde_json::from_str::(content) + .map_err(|_| "Invalid project file format".to_string())?; + if let Some(sections) = payload.get("sections").and_then(Value::as_array) { + for (section_index, section) in sections.iter().enumerate() { + if section + .as_object() + .is_some_and(|section_object| !section_object.contains_key("timeRange")) + { + return Err(format!( + "Invalid project file format: sections[{section_index}].timeRange is required; reanalyze the project to restore section timing." + )); + } + } + } + + serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string()) +} + #[tauri::command] fn save_project(payload: Value) -> Result<(), String> { let parsed = serde_json::from_value::(payload) @@ -913,7 +1013,175 @@ fn load_project() -> Result { } let content = std::fs::read_to_string(path).map_err(|_| "Failed to read file".to_string())?; - serde_json::from_str(&content).map_err(|_| "Invalid project file format".to_string()) + project_payload_from_content(&content) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_test_dir(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("bandscope-{name}-{suffix}")) + } + + fn shared_contract_payload(time_range: Value) -> Value { + json!({ + "id": "demo-song", + "title": "Late Night Set", + "sections": [ + { + "id": "verse-1", + "label": "verse", + "groove": "Straight eighths with a late snare feel", + "timeRange": time_range, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Double-check the pickup into the chorus." + }, + "roles": [ + { + "id": "bass-guitar", + "name": "Bass Guitar", + "roleType": "instrument", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi pedal anchor", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Hold through the pickup before the downbeat." + }, + "range": { + "lowestNote": "C#2", + "highestNote": "E3" + }, + "confidence": { + "level": "medium", + "source": "model", + "notes": "Watch the slide into the turnaround." + }, + "rehearsalPriority": "high", + "simplification": "Stay on roots if the chorus entrance gets muddy.", + "setupNote": "Keep the attack short so the verse breathes.", + "manualOverrides": [], + "overlapWarnings": [ + "Density warning: competing with Keyboard Left Hand in low register." + ] + } + ], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": true, + "handoff_to": ["lead-vocal"], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Start with the verse handoff and low-register overlap.", + "focusSections": ["verse-1"] + } + }) + } + + #[test] + fn rehearsal_song_payload_accepts_shared_section_contract() { + let payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + + let parsed = serde_json::from_value::(payload) + .expect("shared rehearsal song contract should deserialize in Tauri"); + + assert_eq!(parsed.sections[0].id, "verse-1"); + } + + #[test] + fn rehearsal_song_payload_rejects_reversed_time_range() { + let payload = shared_contract_payload(json!({ "start": 30, "end": 10 })); + + assert!(serde_json::from_value::(payload).is_err()); + } + + #[test] + fn project_payload_from_content_rejects_legacy_missing_time_range() { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["sections"][0] + .as_object_mut() + .expect("section should be an object") + .remove("timeRange"); + let content = serde_json::to_string(&payload).expect("legacy payload should serialize"); + + let error = project_payload_from_content(&content) + .expect_err("legacy sections without timing should fail closed"); + + assert!(error.contains("timeRange")); + } + + #[test] + fn youtube_metadata_must_reference_supported_audio_inside_cache_root() { + let cache_root = unique_test_dir("youtube-cache"); + let outside_root = unique_test_dir("youtube-outside"); + std::fs::create_dir_all(&cache_root).expect("cache root should be created"); + std::fs::create_dir_all(&outside_root).expect("outside root should be created"); + + let inside_file = cache_root.join("downloaded.m4a"); + let empty_file = cache_root.join("empty.m4a"); + let unsupported_file = cache_root.join("downloaded.txt"); + let outside_file = outside_root.join("downloaded.m4a"); + std::fs::write(&inside_file, b"audio").expect("inside file should be written"); + std::fs::write(&empty_file, b"").expect("empty file should be written"); + std::fs::write(&unsupported_file, b"not audio") + .expect("unsupported file should be written"); + std::fs::write(&outside_file, b"audio").expect("outside file should be written"); + + let accepted = youtube_source_from_metadata( + &json!({ "filepath": inside_file, "title": "Live/Test" }), + &cache_root, + ) + .expect("in-cache supported audio should be accepted"); + assert_eq!(accepted.extension, "m4a"); + assert_eq!(accepted.file_name, "Live_Test.m4a"); + + assert!(youtube_source_from_metadata( + &json!({ "filepath": empty_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": unsupported_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + assert!(youtube_source_from_metadata( + &json!({ "filepath": outside_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + + #[cfg(unix)] + { + let symlink_file = cache_root.join("linked.m4a"); + std::os::unix::fs::symlink(&inside_file, &symlink_file) + .expect("symlink should be created"); + assert!(youtube_source_from_metadata( + &json!({ "filepath": symlink_file, "title": "Live" }), + &cache_root, + ) + .is_err()); + } + + let _ = std::fs::remove_dir_all(cache_root); + let _ = std::fs::remove_dir_all(outside_root); + } } fn main() { diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index ce12c92..f0819c5 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -6,32 +6,37 @@ const tauriInvoke = vi.fn(); const mockLoadProject = vi.fn(); const mockSaveProject = vi.fn(); -vi.mock("./lib/analysis", () => ({ - createDefaultAnalysisRequest: () => ({ - sourceKind: "demo", - sourceLabel: "Late Night Set", - roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] - }), - selectLocalAudioSource: async () => { - const response = await tauriInvoke("select_local_audio_source", undefined); - if (response?.code) { - return { ok: false, error: response }; - } +vi.mock("./lib/analysis", async (importActual) => { + const actual = await importActual(); - return { ok: true, bootstrap: response }; - }, - startAnalysisJob: (request: unknown) => tauriInvoke("start_analysis_job", { request }), - getAnalysisJobStatus: (jobId: string) => tauriInvoke("get_analysis_job_status", { jobId }), - importYoutubeUrl: async (url: string) => { - const response = await tauriInvoke("import_youtube_url", { url }); - if (response?.code) { - return { ok: false, error: response }; - } - return { ok: true, bootstrap: response }; - }, - loadProject: () => mockLoadProject(), - saveProject: (song: unknown) => mockSaveProject(song) -})); + return { + ...actual, + createDefaultAnalysisRequest: () => ({ + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] + }), + selectLocalAudioSource: async () => { + const response = await tauriInvoke("select_local_audio_source", undefined); + if (response?.code) { + return { ok: false, error: response }; + } + + return { ok: true, bootstrap: response }; + }, + startAnalysisJob: (request: unknown) => tauriInvoke("start_analysis_job", { request }), + getAnalysisJobStatus: (jobId: string) => tauriInvoke("get_analysis_job_status", { jobId }), + importYoutubeUrl: async (url: string) => { + const response = await tauriInvoke("import_youtube_url", { url }); + if (response?.code) { + return { ok: false, error: response }; + } + return { ok: true, bootstrap: response }; + }, + loadProject: () => mockLoadProject(), + saveProject: (song: unknown) => mockSaveProject(song) + }; +}); function succeededResult() { return { @@ -46,7 +51,7 @@ function succeededResult() { sections: [ { id: "verse-1", - label: "Verse 1", + label: "verse", groove: "Straight eighths with a late snare feel", timeRange: { start: 10, end: 30 }, confidence: { @@ -112,8 +117,8 @@ function succeededResult() { ], exportSummary: { format: "cue-sheet", - headline: "Start with Verse 1 entrances before the chorus lift.", - focusSections: ["Verse 1"] + headline: "Start with verse entrances before the chorus lift.", + focusSections: ["verse"] } } }; @@ -126,6 +131,93 @@ describe("App", () => { mockSaveProject.mockReset(); }); + it("renders the rehearsal cockpit shell before analysis starts", () => { + render(); + + expect(screen.getByRole("navigation", { name: /primary rehearsal views/i })).toBeTruthy(); + expect(screen.getByRole("heading", { name: /Workspace Home/i })).toBeTruthy(); + expect(screen.getByText(/SYNCED • LOCAL/i)).toBeTruthy(); + expect(screen.getByText(/Turn a song into a practical rehearsal view\./i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /^Workspace$/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /^Import$/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /^Export$/i })).toBeTruthy(); + expect(screen.getByText(/^Tempo$/i)).toBeTruthy(); + expect(screen.getByText(/^Key$/i)).toBeTruthy(); + expect(screen.getByText(/Local-first/i)).toBeTruthy(); + expect(screen.getByText(/Local project data stays on this device/i)).toBeTruthy(); + expect(screen.getByText(/YouTube import contacts the source provider/i)).toBeTruthy(); + }); + + it("renders the loaded song as a dark rehearsal command board", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); + }); + expect(screen.getByText(/Roles & Harmony/i)).toBeTruthy(); + expect(screen.getByText(/Stems/i)).toBeTruthy(); + expect(screen.getByText(/Rehearsal Priorities/i)).toBeTruthy(); + expect(screen.getByText(/Export Cue Sheet/i)).toBeTruthy(); + }); + + it("renders a rehearsal song structure timeline from real section ranges", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy(); + }); + expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy(); + expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy(); + expect(screen.queryByText(/Mock-board/i)).toBeNull(); + const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i }); + expect(timelineRegion.className).toContain("overflow-x-auto"); + expect(timelineRegion.getAttribute("tabindex")).toBe("0"); + expect(screen.queryByLabelText(/decorative waveform overview/i)).toBeNull(); + }); + + it("does not show unavailable analysis metrics as detected facts", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); + }); + + expect(screen.queryByText(/128 BPM/i)).toBeNull(); + expect(screen.queryByText(/E Major/i)).toBeNull(); + expect(screen.queryByText(/86%/i)).toBeNull(); + expect(screen.queryByText(/entry, dropout/i)).toBeNull(); + expect(screen.queryByText(/Preview-ready lanes/i)).toBeNull(); + expect(screen.getAllByText(/Pending/i).length).toBeGreaterThanOrEqual(2); + }); + + it("summarizes confidence from the lowest-confidence loaded section", async () => { + const loadedProject = succeededResult().result; + loadedProject.sections.push({ + ...loadedProject.sections[0], + id: "chorus-1", + label: "chorus", + confidence: { level: "high", source: "model", notes: "The chorus form is clear." } + }); + mockLoadProject.mockResolvedValueOnce(loadedProject); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + await waitFor(() => { + expect(screen.getByText(/^Medium$/i)).toBeTruthy(); + }); + expect(screen.getAllByText(/2 sections/i).length).toBeGreaterThan(0); + }); + it("selects a local audio source and starts a local-audio analysis job", async () => { tauriInvoke .mockResolvedValueOnce({ @@ -185,6 +277,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy(); }); + expect(screen.getByRole("alert").textContent).toMatch(/choose a wav, mp3, flac, or m4a file/i); expect(screen.queryByText(/analysis failed during execution/i)).toBeNull(); }); @@ -255,6 +348,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/queued for analysis/i)).toBeTruthy(); }); + expect(screen.getAllByRole("status").some((status) => /queued for analysis/i.test(status.textContent ?? ""))).toBe(true); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); @@ -297,6 +391,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByText(/analysis engine is unavailable/i)).toBeTruthy(); }); + expect(screen.getByRole("alert").textContent).toMatch(/analysis engine is unavailable/i); }); it("falls back to a generic failure message when the engine omits details", async () => { @@ -548,6 +643,45 @@ describe("App", () => { }); }); + it("rejects non-allowlisted YouTube URL intake before invoking the bridge", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://example.com/watch?v=123" } }); + + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + expect(tauriInvoke).not.toHaveBeenCalled(); + }); + + it("rejects downgraded YouTube URL intake before invoking the bridge", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "http://youtube.com/watch?v=123" } }); + + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + expect(tauriInvoke).not.toHaveBeenCalled(); + }); + + it("rejects duplicate YouTube video parameters even when one is blank", async () => { + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123&v=" } }); + + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + }); + expect(tauriInvoke).not.toHaveBeenCalled(); + }); + it("loads a project and updates the UI", async () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a51f74d..f783362 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,30 +1,63 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + AudioWaveform, + CircleHelp, + Clock3, + CloudOff, + FileMusic, + FolderOpen, + Gauge, + Home, + KeyRound, + ListMusic, + Music2, + Play, + Save, + Settings, + SlidersHorizontal, + Sparkles, + Star, + Upload, + Users, + Wand2 +} from "lucide-react"; import { SUPPORTED_AUDIO_FORMATS, - type AnalysisJobStatus, type AnalysisJobRequest, + type AnalysisJobStatus, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types"; import { createDefaultAnalysisRequest, getAnalysisJobStatus, - selectLocalAudioSource, importYoutubeUrl, - startAnalysisJob, + isSupportedYoutubeUrl, loadProject, - saveProject + saveProject, + selectLocalAudioSource, + startAnalysisJob } from "./lib/analysis"; import { createTranslator, detectPreferredLocale } from "./i18n"; import { Workspace } from "./features/workspace/Workspace"; -import { EmptyState, LoadingState, ErrorState } from "./features/workspace/WorkspaceStates"; +import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Card, CardContent } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; const ANALYSIS_POLL_INTERVAL_MS = 250; +const NAV_ITEMS = [ + { label: "Workspace", icon: Home, active: true }, + { label: "Import", icon: Upload, active: false }, + { label: "Export", icon: Save, active: false }, + { label: "Sections", icon: ListMusic, active: false }, + { label: "Roles", icon: Users, active: false }, + { label: "Stem Lab", icon: AudioWaveform, active: false }, + { label: "Cues", icon: Sparkles, active: false }, + { label: "Transpose", icon: SlidersHorizontal, active: false }, + { label: "Notes", icon: FileMusic, active: false } +] as const; + /** Documented. */ function progressMessage( t: ReturnType, @@ -42,6 +75,71 @@ function progressMessage( } } +/** Documented. */ +function MetricCard({ + icon, + label, + value, + detail, + accent = "text-cyan-300" +}: { + icon: ReactNode; + label: string; + value: string; + detail: string; + accent?: string; +}) { + return ( +
+
+
+
{icon}
+
+

{label}

+

{value}

+

{detail}

+
+
+
+ ); +} + +/** Documented. */ +function ConfidenceMetric({ song }: { song: RehearsalSong | null }) { + const sectionCount = song?.sections.length ?? 0; + const confidenceOrder = { high: 3, medium: 2, low: 1 } as const; + const lowestConfidence = song?.sections.reduce( + (current, section) => { + if (!current || confidenceOrder[section.confidence.level] < confidenceOrder[current]) { + return section.confidence.level; + } + return current; + }, + null + ); + const confidence = lowestConfidence ? `${lowestConfidence[0].toUpperCase()}${lowestConfidence.slice(1)}` : "Ready"; + const detail = sectionCount > 0 ? `${sectionCount} section${sectionCount === 1 ? "" : "s"}` : "Local analysis"; + + return ( +