diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index 2d799a0..2bd4039 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -61,7 +61,12 @@ pub struct DebugStats { pub cpu_process: f32, pub cpu_system: f32, /// GPU 3D-engine utilization (Task-Manager-style), if counters opened. + /// MACHINE-WIDE — includes DWM, browsers, Discord, everything. pub gpu_percent: Option, + /// Same counters, restricted to OUR process tree — the app's true GPU + /// share, matching Task Manager's per-process rows. The machine-wide + /// number above routinely reads 2× this on a busy desktop. + pub gpu_app_percent: Option, /// Process tree working set / private commit, bytes. pub mem_working_set: u64, pub mem_private: u64, @@ -159,10 +164,14 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS None => (None, None, None), }; + let tree_u32: Vec = tree.iter().map(|p| p.as_u32()).collect(); + let gpu_sample = inner.gpu.as_mut().and_then(|g| g.sample(&tree_u32)); + DebugStats { cpu_process, cpu_system: inner.sys.global_cpu_usage(), - gpu_percent: inner.gpu.as_mut().and_then(|g| g.sample()), + gpu_percent: gpu_sample.map(|(sys, _)| sys), + gpu_app_percent: gpu_sample.map(|(_, app)| app), mem_working_set, mem_private, mem_system_used: inner.sys.used_memory(), @@ -257,7 +266,10 @@ impl GpuCounter { } } - fn sample(&mut self) -> Option { + /// Returns (machine_total, app_tree_total). Instance names carry the + /// owning pid — "pid_1234_luid_..._engtype_3D" — so the app's share is + /// the sum over instances whose pid is in our process tree. + fn sample(&mut self, app_pids: &[u32]) -> Option<(f32, f32)> { use windows::Win32::System::Performance::{ PdhCollectQueryData, PdhGetFormattedCounterArrayW, PDH_FMT_COUNTERVALUE_ITEM_W, PDH_FMT_DOUBLE, @@ -292,14 +304,32 @@ impl GpuCounter { return None; } let mut total = 0.0f64; + let mut app = 0.0f64; for i in 0..count as usize { - total += (*items.add(i)).FmtValue.Anonymous.doubleValue; + let item = &*items.add(i); + let v = item.FmtValue.Anonymous.doubleValue; + total += v; + if !item.szName.is_null() { + if let Some(pid) = instance_pid(item.szName) { + if app_pids.contains(&pid) { + app += v; + } + } + } } - Some(total.min(100.0) as f32) + Some((total.min(100.0) as f32, app.min(100.0) as f32)) } } } +/// Parse the pid out of a "pid_1234_luid_…" counter-instance name. +unsafe fn instance_pid(name: windows::core::PWSTR) -> Option { + let s = name.to_string().ok()?; + let rest = s.strip_prefix("pid_")?; + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse().ok() +} + impl Drop for GpuCounter { fn drop(&mut self) { use windows::Win32::System::Performance::PdhCloseQuery; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2f63a2..ded732e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -113,6 +113,7 @@ pub fn run() { commands::preload_cached_stream, commands::stream_waveform, preview::preview_resolve, + preview::preview_prewarm, preview::preview_load, preview::preview_control, preview::preview_dispose, diff --git a/src-tauri/src/preview.rs b/src-tauri/src/preview.rs index dc2a1c5..2f88bf7 100644 --- a/src-tauri/src/preview.rs +++ b/src-tauri/src/preview.rs @@ -47,10 +47,28 @@ const SAFETY_MARGIN_MS: i64 = 20_000; const FALLBACK_SAFE_MS: i64 = 60_000; const MAX_SAFE_MS: i64 = 30 * 60_000; -/// Clip files older than this are swept on init and on each resolve — a -/// preview session never legitimately needs an hour-old clip. +/// Clip files older than this are swept on init and periodically from the +/// resolve path — a preview session never legitimately needs an hour-old clip. const CLIP_MAX_AGE: Duration = Duration::from_secs(60 * 60); +/// The resolve-path sweep runs at most this often. Sweeping on every resolve +/// was a directory walk + stat per step of a scrolling session for files +/// that age out hourly. +const SWEEP_MIN_INTERVAL: Duration = Duration::from_secs(10 * 60); + +/// Pace between prewarm downloads. Rides ON TOP of the metadata crate's own +/// Deezer throttle; this keeps the walker from ever competing with a live +/// step's resolve for the gate. +const PREWARM_SPACING: Duration = Duration::from_millis(250); + +/// The walker starts after the session's opening resolve (and its ±1 +/// neighbour prefetches) have had the network to themselves. +const PREWARM_INITIAL_DELAY: Duration = Duration::from_millis(1500); + +/// Hard cap on one prewarm call, mirroring the frontend preloader's cap — a +/// 500-item manifest warms its head, not the whole tail. +const PREWARM_CAP: usize = 60; + /// Re-resolving a track whose clip bytes are already on disk skips the /// network entirely within this window (the bytes are immutable audio — only /// the signed URL goes stale, and a local file needs no URL). This is what @@ -96,6 +114,12 @@ pub struct PreviewState { recent_clips: Mutex>, clip_dir: PathBuf, lease_counter: AtomicU64, + /// Generation guard for the session prewarm walker: bumping it (a new + /// prewarm call, or dispose) halts any walker still in flight. + prewarm_gen: Arc, + /// The resolve-path clip sweep runs at most once per SWEEP_MIN_INTERVAL — + /// it used to walk the clip directory on EVERY resolve. + last_sweep: Mutex, } impl PreviewState { @@ -251,6 +275,8 @@ pub fn init(app: &tauri::AppHandle) { recent_clips: Mutex::new(HashMap::new()), clip_dir, lease_counter: AtomicU64::new(1), + prewarm_gen: Arc::new(AtomicU64::new(0)), + last_sweep: Mutex::new(Instant::now()), }); // One forwarder per voice. Only the ACTIVE voice's events reach the UI: @@ -425,25 +451,28 @@ fn measure_clip_gain(app: &tauri::AppHandle, path: &std::path::Path) -> f32 { } } -#[tauri::command] -pub async fn preview_resolve( - app: tauri::AppHandle, - library: State<'_, LibraryState>, - state: State<'_, PreviewState>, +/// A clip fetched, validated, stored on disk and loudness-measured — the +/// shared core of the live resolve and the session prewarm. Registers the +/// clip in `recent_clips`; minting a lease over it is the caller's business. +struct StoredClip { + path: PathBuf, + gain: f32, + byte_length: usize, + safe_for_ms: i64, +} + +async fn fetch_store_clip( + app: &tauri::AppHandle, + library: &LibraryState, + state: &PreviewState, track_id: u64, -) -> Result { - sweep_old_clips(&state.clip_dir); - - if let Some(cached) = mint_from_recent_clip(&state, track_id) { - return Ok(cached); - } - +) -> Result { let Some(body) = library.metadata.deezer_track_uncached(track_id).await else { - return Ok(fail("network", true)); + return Err(fail("network", true)); }; if let Some(err) = body.get("error") { let class = err.get("type").and_then(|t| t.as_str()).unwrap_or(""); - return Ok(match class { + return Err(match class { // Deezer's 200-with-error-body shapes, from Phase 0. "DataException" | "InvalidQueryException" => fail("not_found", false), _ => fail("temporarily_failed", true), @@ -455,16 +484,16 @@ pub async fn preview_resolve( .unwrap_or("") .to_string(); if preview_url.is_empty() { - return Ok(fail("not_provided", false)); + return Err(fail("not_provided", false)); } let media = match metadata::fetch_preview_media(&preview_url).await { Ok(m) => m, - Err(metadata::PreviewFetchError::Status(403)) => return Ok(fail("stale_lease", true)), - Err(metadata::PreviewFetchError::Status(_)) => return Ok(fail("temporarily_failed", true)), - Err(metadata::PreviewFetchError::DisallowedUrl) => return Ok(fail("invalid_media", false)), - Err(metadata::PreviewFetchError::TooLarge) => return Ok(fail("invalid_media", false)), - Err(metadata::PreviewFetchError::Network) => return Ok(fail("network", true)), + Err(metadata::PreviewFetchError::Status(403)) => return Err(fail("stale_lease", true)), + Err(metadata::PreviewFetchError::Status(_)) => return Err(fail("temporarily_failed", true)), + Err(metadata::PreviewFetchError::DisallowedUrl) => return Err(fail("invalid_media", false)), + Err(metadata::PreviewFetchError::TooLarge) => return Err(fail("invalid_media", false)), + Err(metadata::PreviewFetchError::Network) => return Err(fail("network", true)), }; // MIME gate first (the stale-lease body is an HTML error page), then the @@ -477,21 +506,21 @@ pub async fn preview_resolve( .as_deref() .is_some_and(|m| m.starts_with("audio/mpeg")); if !mime_ok { - return Ok(fail("invalid_media", false)); + return Err(fail("invalid_media", false)); } let audio = strip_id3v2(&media.bytes); if !starts_with_mpeg_sync(audio) { - return Ok(fail("invalid_media", false)); + return Err(fail("invalid_media", false)); } let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed); - let lease_id = format!("pv-{track_id}-{nonce}"); - let path = state.clip_dir.join(format!("{lease_id}.mp3")); + let path = state.clip_dir.join(format!("pv-{track_id}-{nonce}.mp3")); if std::fs::write(&path, audio).is_err() { - return Ok(fail("temporarily_failed", true)); + return Err(fail("temporarily_failed", true)); } // Measure loudness off the async worker (a 30 s clip decodes in tens of - // ms). On a prefetched neighbour this is fully off the playback path. + // ms). On a prefetched or prewarmed clip this is fully off the playback + // path. let gain = { let app2 = app.clone(); let path2 = path.clone(); @@ -499,26 +528,61 @@ pub async fn preview_resolve( .await .unwrap_or(1.0) }; - state - .leases - .lock() - .expect("preview lease lock") - .insert(lease_id.clone(), (path.clone(), gain)); + let byte_length = audio.len(); state .recent_clips .lock() .expect("preview clip lock") - .insert(track_id, (path, gain, Instant::now())); + .insert(track_id, (path.clone(), gain, Instant::now())); - let safe_for_ms = - compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms()); + Ok(StoredClip { + path, + gain, + byte_length, + safe_for_ms: compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms()), + }) +} + +#[tauri::command] +pub async fn preview_resolve( + app: tauri::AppHandle, + library: State<'_, LibraryState>, + state: State<'_, PreviewState>, + track_id: u64, +) -> Result { + { + // Throttled sweep: a directory walk per resolve was pure waste in a + // scrolling session; hour-old files don't need minute-fresh reaping. + let mut last = state.last_sweep.lock().expect("preview sweep lock"); + if last.elapsed() > SWEEP_MIN_INTERVAL { + *last = Instant::now(); + sweep_old_clips(&state.clip_dir); + } + } + + if let Some(cached) = mint_from_recent_clip(&state, track_id) { + return Ok(cached); + } + + let stored = match fetch_store_clip(&app, &library, &state, track_id).await { + Ok(s) => s, + Err(failure) => return Ok(failure), + }; + + let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed); + let lease_id = format!("pv-{track_id}-{nonce}"); + state + .leases + .lock() + .expect("preview lease lock") + .insert(lease_id.clone(), (stored.path, stored.gain)); Ok(PreviewResolveResult { ok: true, lease: Some(PreviewLeaseDto { opaque_lease_id: lease_id, - safe_for_ms, - byte_length: audio.len(), + safe_for_ms: stored.safe_for_ms, + byte_length: stored.byte_length, mime_type: "audio/mpeg".into(), clip_duration_ms: 30_000, }), @@ -527,6 +591,50 @@ pub async fn preview_resolve( }) } +/// Best-effort session prewarm: walk the manifest's Deezer track ids in +/// session order and pull every clip onto disk ahead of the user, so a step +/// landing ANYWHERE in the session resolves from `recent_clips` without +/// touching the network — the audio-side twin of the overlay's artwork +/// preloader (Kyle's call 2026-08-01: decode once at open, never during +/// scrolling). Paced gently (PREWARM_SPACING between clips, on top of the +/// metadata crate's own Deezer throttle), capped at PREWARM_CAP, and halted +/// by the generation guard the moment the session closes or a newer session +/// opens. Failures are skipped silently — the live resolve path still owns +/// error classification for anything the user actually lands on. +#[tauri::command] +pub async fn preview_prewarm( + app: tauri::AppHandle, + library: State<'_, LibraryState>, + state: State<'_, PreviewState>, + track_ids: Vec, +) -> Result<(), String> { + let my_gen = state.prewarm_gen.fetch_add(1, Ordering::Relaxed) + 1; + let gen_ctr = state.prewarm_gen.clone(); + let fresh = move || gen_ctr.load(Ordering::Relaxed) == my_gen; + + tokio::time::sleep(PREWARM_INITIAL_DELAY).await; + for track_id in track_ids.into_iter().take(PREWARM_CAP) { + if !fresh() { + return Ok(()); + } + let cached = { + let clips = state.recent_clips.lock().expect("preview clip lock"); + clips + .get(&track_id) + .is_some_and(|(_, _, at)| at.elapsed() < CLIP_REUSE_TTL) + }; + if cached { + continue; + } + let _ = fetch_store_clip(&app, &library, &state, track_id).await; + if !fresh() { + return Ok(()); + } + tokio::time::sleep(PREWARM_SPACING).await; + } + Ok(()) +} + // ------------------------------------------------------------ transport --- /// Load a resolved clip into the preview player. `start_paused` uses the @@ -664,6 +772,8 @@ pub fn preview_control( /// call repeatedly (close, navigation, hot reload all funnel here). #[tauri::command] pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { + // Halt any in-flight session prewarm walker before touching the caches. + state.prewarm_gen.fetch_add(1, Ordering::Relaxed); state.bump_fades(); state.voices[0].stop(); state.voices[1].stop(); @@ -672,11 +782,16 @@ pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { state.set_clip_gain(1, 1.0); state.set_vol(0, state.target()); state.set_vol(1, state.target()); - state - .recent_clips - .lock() - .expect("preview clip lock") - .clear(); + // Drain BOTH maps and delete the files each references — prewarmed clips + // exist only in recent_clips (no lease was ever minted over them), so + // clearing the map without deleting orphaned them until the hourly + // sweep. Shared paths get two remove attempts; the second is a no-op. + { + let mut clips = state.recent_clips.lock().expect("preview clip lock"); + for (_, (path, _gain, _at)) in clips.drain() { + let _ = std::fs::remove_file(path); + } + } let mut leases = state.leases.lock().expect("preview lease lock"); for (_, (path, _gain)) in leases.drain() { let _ = std::fs::remove_file(path); diff --git a/src/App.svelte b/src/App.svelte index ab4de62..d08ac28 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -110,6 +110,15 @@ pointer-events: none; } + /* NOTE (2026-08-01 perf audit): do NOT try to move the preview scrim's + backdrop blur onto these panels as `filter: blur(6px)`. It was tried: + filtered panels do not get cached render passes here (the content + pane's canvas/composited subtree defeats caching), so every presented + frame re-rastered five blurred panels — measured ~2× the cost of the + single backdrop-filter on the scrim. The cheap lever is the modal's + PRESENT RATE (see the eq animation note in PreviewOverlay.svelte), + not where the blur lives. */ + .app { display: grid; grid-template-columns: var(--sidebar-width) 1fr var(--nowplaying-width); diff --git a/src/components/DebugOverlay.svelte b/src/components/DebugOverlay.svelte index 912aa51..7b2c77c 100644 --- a/src/components/DebugOverlay.svelte +++ b/src/components/DebugOverlay.svelte @@ -135,7 +135,10 @@ {#if stats}
CPU app {stats.cpuProcess.toFixed(1)}% · sys {stats.cpuSystem.toFixed(1)}%
-
GPU {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}% (3D)` : "n/a"}
+ +
GPU {stats.gpuAppPercent != null ? `app ${stats.gpuAppPercent.toFixed(1)}%` : "app n/a"} · sys {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}%` : "n/a"} (3D)
RAM ws {mb(stats.memWorkingSet)} MB · reserved {mb(stats.memPrivate)} MB
system {gb(stats.memSystemUsed)} / {gb(stats.memSystemTotal)} GB · {stats.processCount} procs
NET ↓ {rate(stats.netRxBps)} · ↑ {rate(stats.netTxBps)}
diff --git a/src/components/PreviewOverlay.svelte b/src/components/PreviewOverlay.svelte index ed931ec..b8192dc 100644 --- a/src/components/PreviewOverlay.svelte +++ b/src/components/PreviewOverlay.svelte @@ -85,21 +85,129 @@ }); // ----------------------------------------------------- backdrop slots -- - // Two crossfading bleed layers, exactly the mockup's setBackdrop(). The - // effect depends ONLY on the active artwork; the slot bookkeeping is read - // and written under untrack() so it can never invalidate the effect - // (a read-and-write of the same $state is a self-triggering loop). - let bleedA = $state(""); - let bleedB = $state(""); + // Two crossfading bleed layers, the mockup's setBackdrop() — but drawn + // as PRE-BLURRED tiny canvases rather than full-size imgs under a CSS + // blur(70px). A live 70px blur is a render pass over the whole modal; + // static it is cached, but through every step's 420ms crossfade BOTH + // slots were live and the compositor re-ran two full-size blurs per + // presented frame — the bulk of the stepping cost. The blur now happens + // once per artwork on a 96×58 canvas (4px there ≈ 70px at panel scale; + // the GPU's bilinear upscale supplies the rest of the softness), and the + // crossfade blends two small cached textures. Saturate/brightness are + // baked into the same draw. The effect depends ONLY on the active + // artwork; slot bookkeeping is read and written under untrack() so it + // can never invalidate the effect. + const BLEED_W = 96; + const BLEED_H = 58; // modal max aspect 1660×1000 + let bleedElA = $state(null); + let bleedElB = $state(null); + let bleedDrawnA = $state(false); + let bleedDrawnB = $state(false); let bleedSlot = $state(0); + let bleedShownArt = ""; + let bleedGen = 0; + // art URL → pre-filtered 96×58 offscreen canvas. Stepping back to a + // track becomes a tiny canvas blit instead of a full artwork re-decode — + // the decode was the dominant per-step cost of rapid scrolling. Tainted + // canvases (cross-origin art) blit fine; they just can't be read back. + const bleedCache = new Map(); + async function renderBleed(art) { + if (bleedCache.has(art)) return bleedCache.get(art); + const img = new Image(); + img.decoding = "async"; + img.src = art; + try { + await img.decode(); + } catch { + return null; // keep the previous wash rather than flash blank + } + const sw = img.naturalWidth; + const sh = img.naturalHeight; + if (!sw || !sh) return null; + const off = document.createElement("canvas"); + off.width = BLEED_W; + off.height = BLEED_H; + // Cover-crop the artwork to the canvas aspect (the old object-fit). + const scale = Math.max(BLEED_W / sw, BLEED_H / sh); + const w = BLEED_W / scale; + const h = BLEED_H / scale; + const ctx = off.getContext("2d"); + ctx.filter = "saturate(1.2) brightness(0.82) blur(4px)"; + ctx.drawImage(img, (sw - w) / 2, (sh - h) / 2, w, h, 0, 0, BLEED_W, BLEED_H); + bleedCache.set(art, off); + return off; + } + async function drawBleed(canvas, art) { + const off = await renderBleed(art); + if (!off) return false; + const ctx = canvas.getContext("2d"); + ctx.filter = "none"; + ctx.clearRect(0, 0, BLEED_W, BLEED_H); + ctx.drawImage(off, 0, 0); + return true; + } + // -------------------------------------------------------- preloader ---- + // Decode-once, at open: every step needs three things from the active + // artwork — the card's decoded image, the 96×58 wash, the accent pair — + // and none of them should be produced while the user is scrolling + // (per-step decodes were the whole cost of rapid stepping, measured + // 11.9% → 6.1% of a core just from caching). This walks the manifest + // ONCE when a session opens, nearest-to-active first, and fills every + // cache ahead of the user. One item at a time with a yield between, so + // the open animation and first clip resolve are never starved; ~20 + // items are fully warm within a couple of seconds. + $effect(() => { + const m = manifest; + if (!m || !previewUi.open) return; + const start = untrack(() => activeIndex ?? 0); + let cancelled = false; + const arts = []; + const seen = new Set(); + // nearest-first: start, start±1, start±2, … capped at 60 artworks + // so a 500-item manifest doesn't churn for minutes — beyond the cap + // the per-step lazy path (renderBleed's own cache) still applies. + const CAP = 60; + outer: for (let d = 0; d < m.items.length; d++) { + for (const idx of d === 0 ? [start] : [start + d, start - d]) { + const art = m.items[idx]?.artwork; + if (art && !seen.has(art)) { + seen.add(art); + arts.push(art); + if (arts.length >= CAP) break outer; + } + } + } + (async () => { + for (const art of arts) { + if (cancelled) return; + await renderBleed(art); // decodes the image, caches the wash + if (cancelled) return; + if (!accentCache.has(art)) { + const c = await accentFromImage(art); + if (c) accentCache.set(art, c); + } + // Yield so the warm-up never competes with a live step. + await new Promise((r) => setTimeout(r, 60)); + } + })(); + return () => (cancelled = true); + }); + $effect(() => { const art = activeItem?.artwork ?? ""; untrack(() => { - const showing = bleedSlot === 0 ? bleedB : bleedA; - if (art === showing) return; - if (bleedSlot === 0) bleedA = art; - else bleedB = art; - bleedSlot = bleedSlot === 0 ? 1 : 0; + if (!art || art === bleedShownArt) return; + const gen = ++bleedGen; + // slot 0 shows B, so A is hidden and takes the incoming draw. + const target = bleedSlot === 0 ? bleedElA : bleedElB; + if (!target) return; + drawBleed(target, art).then((ok) => { + if (!ok || gen !== bleedGen) return; + bleedShownArt = art; + if (bleedSlot === 0) bleedDrawnA = true; + else bleedDrawnB = true; + bleedSlot = bleedSlot === 0 ? 1 : 0; + }); }); }); @@ -244,6 +352,11 @@ let announce = $state(""); // ------------------------------------------------------- direction ----- + // Steps keep their full animations at EVERY rate (a jump-cut fast path + // was tried 2026-08-01 and rejected on sight). What makes rapid + // stepping affordable instead is the session preloader below: by the + // time a step lands, its wash, accent and artwork are already decoded, + // so the step's cost is the (compositor-cheap) transitions themselves. function step(dir) { stepDir = dir; previewCommands.step(dir); @@ -346,6 +459,19 @@ const MAX_AMP = 15; let waveCanvas = $state(null); let waveWrap = $state(null); + // Width comes from a ResizeObserver, not a per-draw clientWidth read — + // position events arrive 10×/s and each read was a forced layout. + let waveW = $state(0); + $effect(() => { + if (!waveWrap) return; + waveW = waveWrap.clientWidth; + const ro = new ResizeObserver((entries) => { + const w = entries[entries.length - 1]?.contentRect.width; + if (w != null) waveW = Math.round(w); + }); + ro.observe(waveWrap); + return () => ro.disconnect(); + }); const peaksCache = new Map(); function peaksFor(key) { if (peaksCache.has(key)) return peaksCache.get(key); @@ -365,13 +491,14 @@ void st?.positionMs; void activeItem?.sessionKey; void pvAccent; + void waveW; drawWave(); }); function drawWave() { if (!waveCanvas || !waveWrap || !activeItem) return; const dpr = window.devicePixelRatio || 1; - const W = waveWrap.clientWidth; + const W = waveW; if (W <= 0) return; const pw = Math.max(1, Math.round(W * dpr)); const ph = Math.max(1, Math.round(WAVE_H * dpr)); @@ -489,7 +616,6 @@ >