diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 5bb7bdc0..394e73cc 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,7 +3,10 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the release workflow uses the section matching the tag and fails if either is missing. ## [Unreleased] +### What's new +- **Hover feedback** (#295): menu and Configurações blocks and rows (including the icon-only tiles), tabs, shortcuts, the path bar, cards, list and grid rows, on-screen keyboard keys and column headers now lighten slightly (darken in the light theme) under the pointer, always in a neutral tone and never in the accent colour so it can't be mistaken for controller/keyboard focus. The focused item gets no hover, and it only shows while the mouse recently moved: scrolling the list with the controller under a stationary pointer lights nothing (the reason the system highlight was removed in #182). ### Changes +- **Controller sounds replaced by a professional CC0 set** (#287): the synthesized cues were replaced by four short, soft sounds from Kenney's "Interface Sounds" pack (CC0, free redistribution; origin and licence documented in THIRD_PARTY_NOTICES.md and assets/sounds): move, mark, confirm and back, trimmed, click-free and at the same energy. The "Sons do controle" setting (off/low/medium/high/max) still applies on top. The PS5 interface was only a feel reference. - **Settings as one wide column of icon-only tiles** (#293, replaces the two-column design of 0.14): every setting is now an icon-only tile, with no text beside it, in groups inside a single wide column (no parallel columns). The focused tile fills in and its **name, value and what it does** show in the panel's fixed reading area (and in a hover tooltip); Narrator still reads name and value. L1/R1 jump between groups and Configurações stays the first tile of the Menu. No setting was removed or changed behavior. ## [0.14.0-alpha.1] diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a07b79..90aeff71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` **nos dois arquivos**. O workflow de release usa a seção da tag e falha se faltar alguma. ## [Unreleased] +### Novidades +- **Retorno ao passar o mouse** (#295): blocos e linhas de menu e de Configurações (inclusive os blocos só de ícone), abas, atalhos, caminho, cartões, linhas da lista e da grade, teclas do teclado virtual e cabeçalhos de coluna agora clareiam de leve (escurecem no tema claro) quando o ponteiro está em cima, sempre num tom neutro, nunca na cor de destaque, para não se confundir com o foco do controle/teclado. O item focado não recebe hover, e ele só aparece enquanto o mouse se mexeu há pouco: rolar a lista com o controle sob um ponteiro parado não acende nada (o motivo de o destaque do sistema ter saído na #182). ### Mudanças +- **Sons do controle trocados por um conjunto profissional CC0** (#287): os toques sintetizados foram substituídos por quatro sons curtos e suaves do pacote "Interface Sounds" de Kenney (licença CC0, redistribuição livre; origem e licença documentadas em THIRD_PARTY_NOTICES.md e assets/sounds): mover, marcar, confirmar e voltar, aparados, sem estalo e com a mesma energia. O ajuste "Sons do controle" (desligado/baixo/médio/alto/máximo) continua valendo sobre eles. A interface do PS5 foi só referência de sensação. - **Configurações numa coluna larga, só com ícones** (#293, refaz o desenho em duas colunas da 0.14): todos os ajustes viram blocos só de ícone, sem texto ao lado, em grupos numa única coluna larga (sem colunas paralelas). O bloco em foco se preenche e o **nome, o valor e o que o ajuste faz** aparecem na área de leitura fixa do painel (e numa dica ao passar o mouse); o Narrador continua lendo nome e valor. L1/R1 pulam de grupo e Configurações segue como primeiro bloco do Menu. Nenhum ajuste foi removido ou mudou de comportamento. ## [0.14.0-alpha.1] diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 99e26939..c7d87397 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -101,9 +101,12 @@ AGPL-3.0-only deste código e não podem ser reutilizadas sem permissão dos don Sons do controle ------------------------------------------------------------------------------ -Os toques de mover, confirmar, voltar e marcar (src/ControlFS.Core/Audio/ToneSynth.cs) são sintetizados em código neste -projeto: ondas senoidais com dois harmônicos e um envelope próprio. Nenhum arquivo de áudio, biblioteca de sons ou -amostra de terceiros é incluído, então não há licença de áudio externa: os sons valem sob a AGPL-3.0-only do projeto. A -interface do PS5 é apenas referência de sensação (sons curtos, suaves e limpos); nada da Sony foi copiado ou imitado nota por nota. +Os quatro toques de mover, confirmar, voltar e marcar (assets/sounds/*.wav, embutidos no aplicativo) vêm do pacote +"Interface Sounds" de Kenney (https://kenney.nl/assets/interface-sounds), licença Creative Commons Zero (CC0 1.0, domínio +público): uso pessoal, educacional e comercial e redistribuição livres, sem exigir crédito (o crédito a Kenney é voluntário e +está aqui). Usados: select_002 (mover), glass_006 (marcar), confirmation_001 (confirmar) e back_004 (voltar), aparados, com fade +de 2 ms e mesma energia por tools/make-sounds.py. A licença original está em assets/sounds/LICENSE-Kenney-CC0.txt. Por ser +CC0, não limita a AGPL-3.0-only do restante do projeto. A interface do PS5 é apenas referência de sensação; nenhum som da Sony +foi usado ou imitado. O Windows e seus serviços não fazem parte deste código aberto. diff --git a/assets/sounds/LICENSE-Kenney-CC0.txt b/assets/sounds/LICENSE-Kenney-CC0.txt new file mode 100644 index 00000000..1fc5acf8 --- /dev/null +++ b/assets/sounds/LICENSE-Kenney-CC0.txt @@ -0,0 +1,22 @@ + + + Interface Sounds (1.0) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 11-02-2020 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/assets/sounds/back.wav b/assets/sounds/back.wav new file mode 100644 index 00000000..6239ee86 Binary files /dev/null and b/assets/sounds/back.wav differ diff --git a/assets/sounds/confirm.wav b/assets/sounds/confirm.wav new file mode 100644 index 00000000..17be3c8b Binary files /dev/null and b/assets/sounds/confirm.wav differ diff --git a/assets/sounds/move.wav b/assets/sounds/move.wav new file mode 100644 index 00000000..838fa723 Binary files /dev/null and b/assets/sounds/move.wav differ diff --git a/assets/sounds/select.wav b/assets/sounds/select.wav new file mode 100644 index 00000000..ee558ea1 Binary files /dev/null and b/assets/sounds/select.wav differ diff --git a/docs/GUIDE.md b/docs/GUIDE.md index be47f78f..499287ae 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -308,7 +308,7 @@ The `controlfs://` scheme is registered in Windows by the installer (and re-regi ## Controller sounds -Menu → Configurações → **Sons do controle** plays short, soft cues for what you do with the controller: a tick when focus moves, a rising note for confirm/open, a falling one for back and a small blip when you mark an item. They are **on by default at medium volume**; choose **desligados** (off), **baixo**, **médio**, **alto** or **máximo** (your choice is kept across restarts and updates). They play only for controller actions (never the keyboard), don't repeat faster than about 12 times a second when you hold the D-pad, use the default Windows audio output and never change a button or how navigation works. The sounds are generated inside the app (no audio files). +Menu → Configurações → **Sons do controle** plays short, soft cues for what you do with the controller: a tick when focus moves, a rising note for confirm/open, a falling one for back and a small blip when you mark an item. They are **on by default at medium volume**; choose **desligados** (off), **baixo**, **médio**, **alto** or **máximo** (your choice is kept across restarts and updates). They play only for controller actions (never the keyboard), don't repeat faster than about 12 times a second when you hold the D-pad, use the default Windows audio output and never change a button or how navigation works. The four sounds are short CC0 cues from Kenney's "Interface Sounds" pack, embedded in the app (licence in THIRD_PARTY_NOTICES.md). ## Screen readers diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index d7282f72..1525fb08 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -308,7 +308,7 @@ O esquema `controlfs://` é registrado no Windows pelo instalador (e de novo ao ## Sons do controle -Menu → Configurações → **Sons do controle** toca sons curtos e suaves para o que você faz com o controle: um tique ao mover o foco, uma nota que sobe ao confirmar/abrir, uma que desce ao voltar e um toque pequeno ao marcar um item. Vêm **ligados em volume médio**; escolha **desligados**, **baixo**, **médio**, **alto** ou **máximo** (a escolha fica salva ao reabrir e ao atualizar). Tocam só nas ações do controle (nunca do teclado), não repetem mais que umas 12 vezes por segundo ao segurar o direcional, usam a saída de áudio padrão do Windows e nunca mudam um botão nem a navegação. Os sons são gerados dentro do app (sem arquivos de áudio). +Menu → Configurações → **Sons do controle** toca sons curtos e suaves para o que você faz com o controle: um tique ao mover o foco, uma nota que sobe ao confirmar/abrir, uma que desce ao voltar e um toque pequeno ao marcar um item. Vêm **ligados em volume médio**; escolha **desligados**, **baixo**, **médio**, **alto** ou **máximo** (a escolha fica salva ao reabrir e ao atualizar). Tocam só nas ações do controle (nunca do teclado), não repetem mais que umas 12 vezes por segundo ao segurar o direcional, usam a saída de áudio padrão do Windows e nunca mudam um botão nem a navegação. Os quatro sons são toques curtos CC0 do pacote "Interface Sounds" de Kenney, embutidos no app (licença em THIRD_PARTY_NOTICES.md). ## Leitores de tela diff --git a/docs/TESTING.md b/docs/TESTING.md index ae813c26..fc295416 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -866,3 +866,8 @@ Só a CI (WinRAR 7.23 pelo winget, arquivo criado pelo Rar.exe real e extraído - [ ] Instalação nova (ou apagar `settings.json`): os sons já tocam ao navegar com o controle, sem ligar nada. Em Configurações → Sons do controle aparece "médio". - [ ] Escolher "desligados", fechar e reabrir (e atualizar): continua mudo. Escolher "baixo": continua baixo. - [ ] Ouvir os quatro sons (mover, confirmar, voltar, marcar) em fones e em alto-falante: limpos, sem estalo, do mesmo volume, distintos entre si; segurar o direcional não vira chiado; "baixo" é realmente baixo. + +## Hover e sons CC0 (#295, #287) — não validado em hardware +- [ ] Mouse em cima de um bloco de Configurações, de uma linha de menu, de uma aba, de um atalho e de uma linha da lista: o item clareia de leve (escurece no tema claro), sem cor de destaque; ao tirar o mouse volta. O item focado não muda. +- [ ] Mover o foco com o controle/teclado sobre uma lista com o ponteiro parado: nenhuma linha "acende" por hover (só o foco de sempre). +- [ ] Os quatro sons novos (mover, marcar, confirmar, voltar) agradam e têm volume parecido; "baixo" é realmente baixo. diff --git a/src/ControlFS.App/Controls/Hover.cs b/src/ControlFS.App/Controls/Hover.cs new file mode 100644 index 00000000..2e35cb90 --- /dev/null +++ b/src/ControlFS.App/Controls/Hover.cs @@ -0,0 +1,117 @@ +using System.Runtime.CompilerServices; +using ControlFS.App.Resources; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; + +namespace ControlFS.App.Controls; + +/// +/// Retorno de "mouse em cima" (#295): um clareado suave (escurecido no tema claro), neutro, nunca na cor de destaque, para que o +/// que está sob o ponteiro nunca se pareça com o foco do controle/teclado (#182: o destaque do sistema sumiu justamente porque a +/// lista rolava sob o ponteiro parado e parecia um segundo foco). Por isso só aparece enquanto o mouse se mexeu há pouco +/// (): rolar ou mover o foco com o controle debaixo de um ponteiro parado não acende nada. O elemento +/// focado nunca recebe hover (o foco manda), e o fundo de antes volta ao sair. Sem animação própria além da troca curta do fundo. +/// +internal static class Hover +{ + /// Por quanto tempo depois de mexer o mouse um elemento novo sob o ponteiro ainda conta como "em cima". + private static readonly TimeSpan Window = TimeSpan.FromSeconds(1.5); + + private static long _lastMove = long.MinValue; + + /// Opacidade de um elemento sob o ponteiro quando o fundo dele não tem um tom de hover próprio (teclas, chips). + private const double DimmedOpacity = 0.82; + + private sealed class State + { + public bool Focused; + public bool Card; + public Brush? Rest; + public bool Hovering; + } + + private static readonly ConditionalWeakTable Tracked = []; + + /// A janela chama a cada movimento do ponteiro: só mouse e caneta (toque não tem "em cima"). + public static void NoteMove(PointerRoutedEventArgs e) + { + if (e.Pointer.PointerDeviceType != PointerDeviceType.Touch) _lastMove = Environment.TickCount64; + } + + private static bool IsMouseActive(PointerRoutedEventArgs e) => + e.Pointer.PointerDeviceType != PointerDeviceType.Touch && Environment.TickCount64 - _lastMove < Window.TotalMilliseconds; + + /// + /// Anel de foco (linhas, abas, atalhos, cartões): registra o tratamento uma vez por elemento (elementos reciclados não + /// acumulam tratadores) e guarda se ele está focado. Chamado por e . + /// + public static void Track(Border border, bool focused, bool card) + { + var state = Tracked.GetValue(border, b => + { + var created = new State(); + b.PointerEntered += (_, e) => + { + if (created.Focused || !IsMouseActive(e)) return; + created.Rest = b.Background; + created.Hovering = true; + b.Background = created.Card ? Theme.HoverCard : Theme.HoverWash; + }; + b.PointerExited += (_, _) => Release(b, created); + b.PointerCanceled += (_, _) => Release(b, created); + return created; + }); + state.Focused = focused; + state.Card = card; + state.Hovering = false; // quem chama acabou de pôr o fundo dele: o hover anterior já não vale + } + + private static void Release(Border border, State state) + { + if (!state.Hovering) return; + state.Hovering = false; + if (!state.Focused) border.Background = state.Rest; + } + + /// + /// Elemento reconstruído a cada mudança (blocos e linhas de menu, teclas): é o fundo sob o ponteiro; + /// sem um, o elemento inteiro fica um pouco mais apagado. Não faz nada se for falso (focado, indisponível). + /// + public static void Attach(Border element, bool active, Brush? hover = null) + { + if (!active) return; + Brush? rest = null; + element.PointerEntered += (_, e) => + { + if (!IsMouseActive(e)) return; + if (hover is null) element.Opacity = DimmedOpacity; + else + { + rest = element.Background; + element.Background = hover; + } + }; + void Leave(object? sender, PointerRoutedEventArgs e) + { + if (hover is null) element.Opacity = 1; + else if (rest is not null) element.Background = rest; + rest = null; + } + element.PointerExited += Leave; + element.PointerCanceled += Leave; + } + + /// Qualquer elemento clicável sem fundo próprio (células de cabeçalho, chips): só a opacidade muda. + public static void AttachDim(FrameworkElement element) + { + element.PointerEntered += (_, e) => + { + if (IsMouseActive(e)) element.Opacity = DimmedOpacity; + }; + element.PointerExited += (_, _) => element.Opacity = 1; + element.PointerCanceled += (_, _) => element.Opacity = 1; + } +} diff --git a/src/ControlFS.App/Resources/Theme.cs b/src/ControlFS.App/Resources/Theme.cs index 8d1379de..6c383b73 100644 --- a/src/ControlFS.App/Resources/Theme.cs +++ b/src/ControlFS.App/Resources/Theme.cs @@ -89,6 +89,9 @@ public static bool Apply(ThemePalette palette) Set(ModalEdge, palette.ModalEdge); Set(ModalDivider, palette.ModalDivider); Set(ModalInset, palette.ModalInset); + HoverWash.Color = Hover(palette, palette.Text, 0x1A); + HoverInset.Color = Blend(palette.ModalInset, palette.Text, 0.12); + HoverCard.Color = Blend(palette.SurfaceRaised, palette.Text, 0.09); Set(KeyFill, palette.KeyFill); Set(KeyFunctionFill, palette.KeyFunctionFill); Set(FocusText, palette.FocusText); @@ -170,6 +173,7 @@ public static void ApplyFocus(Microsoft.UI.Xaml.Controls.Border border, bool foc border.BorderThickness = FocusRing; border.BorderBrush = focused ? Accent : Transparent; border.Background = focused ? AccentSoft : Transparent; + ControlFS.App.Controls.Hover.Track(border, focused, card: false); if (border.Parent is Microsoft.UI.Xaml.Controls.Border { Tag: GlowTag } glow) glow.BorderBrush = focused ? FocusGlow : Transparent; } @@ -188,6 +192,7 @@ public static void ApplyCardFocus(Microsoft.UI.Xaml.Controls.Border card, bool f card.BorderThickness = FocusRing; card.BorderBrush = focused ? Accent : Border; card.Background = focused ? AccentSoft : SurfaceRaised; + ControlFS.App.Controls.Hover.Track(card, focused, card: true); if (card.Parent is Microsoft.UI.Xaml.Controls.Border { Tag: GlowTag } glow) { glow.BorderBrush = focused ? FocusGlow : Transparent; @@ -255,6 +260,23 @@ public static Brush ModalPanel() => SolidSurfaces public static readonly SolidColorBrush ModalDivider = new(ToColor(Palette.ModalDivider)); public static readonly SolidColorBrush ModalInset = new(ToColor(Palette.ModalInset)); + /// + /// "Mouse em cima" (#295): neutro e suave, derivado do texto do tema (clareia no escuro, escurece no claro), nunca na cor de + /// destaque (essa é do foco). vai sobre fundo transparente (linhas), no lugar do + /// fundo de blocos dos modais e no de cartões. + /// + public static readonly SolidColorBrush HoverWash = new(Hover(Palette, Palette.Text, 0x1A)); + public static readonly SolidColorBrush HoverInset = new(Blend(Palette.ModalInset, Palette.Text, 0.12)); + public static readonly SolidColorBrush HoverCard = new(Blend(Palette.SurfaceRaised, Palette.Text, 0.09)); + + private static Color Hover(ThemePalette _, uint text, byte alpha) => ToColor((text & 0x00FFFFFF) | ((uint)alpha << 24)); + + private static Color Blend(uint from, uint to, double t) + { + byte Mix(int shift) => (byte)Math.Round((((from >> shift) & 0xFF) * (1 - t)) + (((to >> shift) & 0xFF) * t)); + return ToColor(0xFF000000u | ((uint)Mix(16) << 16) | ((uint)Mix(8) << 8) | Mix(0)); + } + /// Teclas do teclado virtual: de caractere (mais marcadas) e de função, sempre distintas do painel. public static readonly SolidColorBrush KeyFill = new(ToColor(Palette.KeyFill)); public static readonly SolidColorBrush KeyFunctionFill = new(ToColor(Palette.KeyFunctionFill)); diff --git a/src/ControlFS.App/Views/ListHeaderView.cs b/src/ControlFS.App/Views/ListHeaderView.cs index f483c6fe..e5f67d22 100644 --- a/src/ControlFS.App/Views/ListHeaderView.cs +++ b/src/ControlFS.App/Views/ListHeaderView.cs @@ -135,6 +135,7 @@ private void Title(SortField field, string text, int column, double fontSize) cell.Children.Add(label); cell.Children.Add(arrow); cell.Tapped += (_, _) => _app.PointerSortBy(field); + Controls.Hover.AttachDim(cell); Grid.SetColumn(cell, column); _grid.Children.Add(cell); _titles[field] = (label, arrow, cell); diff --git a/src/ControlFS.App/Views/MainWindow.cs b/src/ControlFS.App/Views/MainWindow.cs index 86b7d010..6bffdeb2 100644 --- a/src/ControlFS.App/Views/MainWindow.cs +++ b/src/ControlFS.App/Views/MainWindow.cs @@ -219,6 +219,8 @@ internal MainWindow(string? dataDirectory) AppLog.Info("MainWindow: serviços criados; montando layout"); Content = _root; _root.Content = _layout = BuildLayout(); + // Hover (#295): só vale enquanto o mouse se mexeu há pouco; o controle rolando a lista sob um ponteiro parado não acende nada. + _root.AddHandler(UIElement.PointerMovedEvent, new Microsoft.UI.Xaml.Input.PointerEventHandler((_, e) => Controls.Hover.NoteMove(e)), handledEventsToo: true); AppLog.Info($"MainWindow: layout montado ({startup.ElapsedMilliseconds} ms)"); _root.PreviewKeyDown += (_, e) => { diff --git a/src/ControlFS.App/Views/ModalView.cs b/src/ControlFS.App/Views/ModalView.cs index 9d6092a2..4065e92b 100644 --- a/src/ControlFS.App/Views/ModalView.cs +++ b/src/ControlFS.App/Views/ModalView.cs @@ -514,6 +514,7 @@ private static Border Row(string label, string glyph, bool focused, bool enabled } AutomationProperties.SetName(row, label + (enabled ? string.Empty : ", indisponível") + (destructive ? ", ação perigosa" : string.Empty) + (!destructive && trailing is { Length: > 0 } ? ", " + trailing : string.Empty)); row.Tapped += (_, _) => onTap(); + Hover.Attach(row, active: !focused && enabled, Theme.HoverWash); return row; } @@ -773,6 +774,7 @@ private static Border MenuTile(AppController app, MenuModal menu, int index) } AutomationProperties.SetName(tile, item.Label + (enabled ? string.Empty : ", indisponível") + (destructive ? ", ação perigosa" : string.Empty)); tile.Tapped += (_, _) => app.PointerChooseModalOption(index); + Hover.Attach(tile, active: !focused && enabled, Theme.HoverInset); return tile; } @@ -807,6 +809,7 @@ private static Border IconTile(AppController app, MenuModal menu, int index, UIE AutomationProperties.SetName(tile, name); ToolTipService.SetToolTip(tile, item.Label); tile.Tapped += (_, _) => app.PointerChooseModalOption(index); + Hover.Attach(tile, active: !focused && enabled, Theme.HoverInset); return tile; } @@ -1218,6 +1221,7 @@ private static void FillKeyboardTop(AppController app, KeyboardModal modal, Stac AutomationProperties.SetName(chip, $"Sugestão: {suggestions[i]}"); if (focused) KeepInView(chip); chip.Tapped += (_, _) => app.PointerPressSuggestion(index); + Hover.Attach(chip, active: !focused); strip.Children.Add(chip); } stack.Children.Add(new ScrollViewer @@ -1271,6 +1275,7 @@ private static Border KeyCell(AppController app, VirtualKeyboard kb, int r, int } AutomationProperties.SetName(cell, key.Name + (isCurrentPage ? ", página atual" : string.Empty) + (enabled ? string.Empty : ", indisponível neste campo")); cell.Tapped += (_, _) => app.PointerPressKey(row, keyIndex); + Hover.Attach(cell, active: !focused && enabled); Grid.SetRow(cell, r); Grid.SetColumn(cell, column); Grid.SetColumnSpan(cell, key.Span); diff --git a/src/ControlFS.App/Views/TutorialOverlayView.cs b/src/ControlFS.App/Views/TutorialOverlayView.cs index f9e73a5e..e7c3857c 100644 --- a/src/ControlFS.App/Views/TutorialOverlayView.cs +++ b/src/ControlFS.App/Views/TutorialOverlayView.cs @@ -117,6 +117,7 @@ private Border BuildCard(TutorialCard card, IReadOnlyList _app.Handle(InputAction.ToggleSelection); + Controls.Hover.AttachDim(chip); exits.Children.Add(chip); if (card.Number > 1) exits.Children.Add(LinkButton("Voltar passo", _app.PreviousTutorialStep)); exits.Children.Add(LinkButton("Pular tutorial", _app.SkipTutorial)); @@ -162,6 +163,7 @@ private static Border LinkButton(string text, Action onTap) }; AutomationProperties.SetName(button, text); button.Tapped += (_, _) => onTap(); + Controls.Hover.AttachDim(button); return button; } diff --git a/src/ControlFS.Core/Audio/SoundBank.cs b/src/ControlFS.Core/Audio/SoundBank.cs new file mode 100644 index 00000000..831033a4 --- /dev/null +++ b/src/ControlFS.Core/Audio/SoundBank.cs @@ -0,0 +1,57 @@ +using System.Reflection; + +namespace ControlFS.Core.Audio; + +/// +/// Os sons do controle (#276, #287): quatro toques curtos do pacote "Interface Sounds" de Kenney (licença CC0, domínio público, +/// redistribuição livre; origem e licença em assets/sounds e THIRD_PARTY_NOTICES.md), já aparados, sem estalo e com a mesma energia +/// (tools/make-sounds.py). Ficam embutidos no assembly; aqui só se aplica o volume do usuário, sempre para baixo a partir do nível +/// mestre (curva perceptiva: 25% soa bem mais baixo que 100%). A interface do PS5 é só referência de sensação: nada da Sony foi usado. +/// +public static class SoundBank +{ + public const int SampleRate = 44100; + private const int HeaderBytes = 44; + + private static readonly Dictionary Masters = []; + + private static string FileFor(SoundCue cue) => cue switch + { + SoundCue.Move => "move", + SoundCue.Select => "select", + SoundCue.Confirm => "confirm", + _ => "back", + }; + + /// O WAV do som (PCM 16 bits, mono, 44,1 kHz) no volume de 1 a 100. + public static byte[] Render(SoundCue cue, int volume) + { + var master = Master(cue); + var wav = (byte[])master.Clone(); + var gain = Math.Pow(Math.Clamp(volume, 1, 100) / 100.0, 1.5); + for (var i = HeaderBytes; i + 1 < wav.Length; i += 2) + { + var sample = (short)Math.Round(BitConverter.ToInt16(master, i) * gain); + wav[i] = (byte)(sample & 0xFF); + wav[i + 1] = (byte)((sample >> 8) & 0xFF); + } + return wav; + } + + private static byte[] Master(SoundCue cue) + { + lock (Masters) + { + if (Masters.TryGetValue(cue, out var cached)) return cached; + var name = $"sounds/{FileFor(cue)}.wav"; + using var stream = typeof(SoundBank).Assembly.GetManifestResourceStream(name) + ?? throw new InvalidOperationException($"Som embutido ausente: {name}"); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + var bytes = memory.ToArray(); + if (bytes.Length <= HeaderBytes || bytes[36] != 'd' || bytes[37] != 'a' || bytes[38] != 't' || bytes[39] != 'a') + throw new InvalidOperationException($"Som embutido inválido (esperado WAV PCM de 44 bytes de cabeçalho): {name}"); + return Masters[cue] = bytes; + } + } +} diff --git a/src/ControlFS.Core/Audio/ToneSynth.cs b/src/ControlFS.Core/Audio/ToneSynth.cs deleted file mode 100644 index a30e7214..00000000 --- a/src/ControlFS.Core/Audio/ToneSynth.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace ControlFS.Core.Audio; - -/// -/// Os sons do controle, sintetizados aqui (#276, #287): toques curtos de timbre "vidro" (fundamental com dois harmônicos -/// suaves), ataque arredondado e queda exponencial, sem nenhum arquivo de terceiros. A sensação discreta e limpa da interface -/// do PS5 é só referência de estilo; nada foi copiado. Saem como WAV PCM de 16 bits, mono, 44,1 kHz, prontos para tocar da memória. -/// Todos têm a mesma energia (RMS), então nenhum som "grita" mais que outro, e começam e terminam em silêncio (sem estalo). -/// -public static class ToneSynth -{ - public const int SampleRate = 44100; - - /// RMS de cada som no volume 100 (~ -21 dBFS): presente, mas nunca alto. - private const double TargetRms = 0.09; - - /// Pico máximo (60% do fundo de escala) mesmo no volume 100: margem para o som nunca saturar. - private const double PeakCap = 0.6; - - /// Uma nota: frequência, começo, duração total e constante de queda (tempo para cair a ~37%). - private readonly record struct Note(double Hz, double StartMs, double Ms, double DecayMs); - - // Escala pentatônica maior (sem dissonância por mais que se repita): E5 659, G#5 831, B5 988, D6 1175. - private static Note[] Notes(SoundCue cue) => cue switch - { - SoundCue.Move => [new(988, 0, 45, 11)], - SoundCue.Select => [new(1319, 0, 70, 16)], - SoundCue.Confirm => [new(659, 0, 80, 22), new(988, 55, 130, 34)], // sobe - SoundCue.Back => [new(988, 0, 80, 22), new(659, 55, 130, 34)], // desce - _ => [new(988, 0, 45, 11)], - }; - - /// WAV do som no volume de 1 a 100 (curva perceptiva: o volume baixo é mesmo baixo). - public static byte[] Render(SoundCue cue, int volume) - { - var notes = Notes(cue); - var length = (int)(SampleRate * notes.Max(n => n.StartMs + n.Ms) / 1000.0) + 1; - var mix = new double[length]; - foreach (var note in notes) - { - var start = (int)(SampleRate * note.StartMs / 1000.0); - var count = (int)(SampleRate * note.Ms / 1000.0); - var attack = Math.Max(1, (int)(SampleRate * 0.004)); // 4 ms: sem estalo - var release = Math.Max(1, (int)(SampleRate * 0.012)); // 12 ms finais até o silêncio exato - for (var i = 0; i < count && start + i < length; i++) - { - var t = (double)i / SampleRate; - var phase = 2 * Math.PI * note.Hz * t; - var tone = Math.Sin(phase) + (0.22 * Math.Sin(2 * phase)) + (0.06 * Math.Sin(3 * phase)); - var envelope = Math.Exp(-(i * 1000.0 / SampleRate) / note.DecayMs); - if (i < attack) envelope *= 0.5 - (0.5 * Math.Cos(Math.PI * i / attack)); - if (i >= count - release) envelope *= 0.5 + (0.5 * Math.Cos(Math.PI * (i - (count - release)) / release)); - mix[start + i] += tone * envelope; - } - } - // Mesma energia para todos os sons; o volume escolhido escala com curva 1,5 (25% soa bem mais baixo que 100%). - var rms = Math.Sqrt(mix.Sum(v => v * v) / mix.Length); - var level = Math.Pow(Math.Clamp(volume, 1, 100) / 100.0, 1.5); - var gain = rms > 0 ? TargetRms * level / rms : 0; - var peak = mix.Max(Math.Abs); - if (peak * gain > PeakCap) gain = PeakCap / peak; - - using var memory = new MemoryStream(); - using var writer = new BinaryWriter(memory); - var dataBytes = length * 2; - writer.Write("RIFF"u8); - writer.Write(36 + dataBytes); - writer.Write("WAVEfmt "u8); - writer.Write(16); - writer.Write((short)1); // PCM - writer.Write((short)1); // mono - writer.Write(SampleRate); - writer.Write(SampleRate * 2); - writer.Write((short)2); - writer.Write((short)16); - writer.Write("data"u8); - writer.Write(dataBytes); - foreach (var value in mix) writer.Write((short)Math.Round(Math.Clamp(value * gain, -1, 1) * short.MaxValue)); - writer.Flush(); - return memory.ToArray(); - } -} diff --git a/src/ControlFS.Core/ControlFS.Core.csproj b/src/ControlFS.Core/ControlFS.Core.csproj index 7cfc9188..6e7ba01c 100644 --- a/src/ControlFS.Core/ControlFS.Core.csproj +++ b/src/ControlFS.Core/ControlFS.Core.csproj @@ -7,4 +7,8 @@ + + + + diff --git a/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs b/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs index c9c2b631..1840aa68 100644 --- a/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs +++ b/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs @@ -6,7 +6,7 @@ namespace ControlFS.Infrastructure.Windows.Shell; /// /// Toca os sons do controle (#276) pelo dispositivo de áudio padrão do Windows (winmm PlaySound, da memória e assíncrono: -/// volta na hora e um som novo substitui o anterior). Cada som é sintetizado uma vez por volume e fica na memória +/// volta na hora e um som novo substitui o anterior). Cada som (WAV embutido) é preparado uma vez por volume e fica na memória /// nativa enquanto o app roda. Sem dispositivo de som, PlaySound só devolve falso: nada quebra. /// public sealed partial class WindowsSoundPlayer : ISoundPlayer, IDisposable @@ -22,7 +22,7 @@ public void Play(SoundCue cue, int volume) var level = Math.Clamp((volume + 4) / 5 * 5, 5, 100); if (!_sounds.TryGetValue((cue, level), out var memory)) { - var wav = ToneSynth.Render(cue, level); + var wav = SoundBank.Render(cue, level); memory = Marshal.AllocHGlobal(wav.Length); Marshal.Copy(wav, 0, memory, wav.Length); _sounds[(cue, level)] = memory; diff --git a/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs b/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs index a3731da4..da78fd85 100644 --- a/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs +++ b/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs @@ -77,37 +77,34 @@ private sealed class Throwing : ISoundPlayer [Fact] public void Synthesized_cues_are_short_valid_wav_files_quieter_at_lower_volume_and_different_from_each_other() { - var bytes = Enum.GetValues().ToDictionary(c => c, c => ToneSynth.Render(c, 50)); + var bytes = Enum.GetValues().ToDictionary(c => c, c => SoundBank.Render(c, 50)); foreach (var (cue, wav) in bytes) { Assert.Equal("RIFF", System.Text.Encoding.ASCII.GetString(wav, 0, 4)); Assert.Equal("WAVE", System.Text.Encoding.ASCII.GetString(wav, 8, 4)); var data = BitConverter.ToInt32(wav, 40); Assert.Equal(wav.Length - 44, data); - Assert.InRange(data / 2.0 / ToneSynth.SampleRate, 0.02, 0.2); // curtos: 20 a 200 ms + Assert.InRange(data / 2.0 / SoundBank.SampleRate, 0.02, 0.35); // curtos: 20 a 350 ms Assert.True(Peak(wav) > 500, $"{cue}: sem sinal"); } - Assert.True(Peak(ToneSynth.Render(SoundCue.Confirm, 100)) > Peak(ToneSynth.Render(SoundCue.Confirm, 25)) * 3); - Assert.InRange(Peak(ToneSynth.Render(SoundCue.Confirm, 100)), 1, (int)(short.MaxValue * 0.6)); // discreto mesmo no máximo + Assert.True(Peak(SoundBank.Render(SoundCue.Confirm, 100)) > Peak(SoundBank.Render(SoundCue.Confirm, 25)) * 3); + Assert.InRange(Peak(SoundBank.Render(SoundCue.Confirm, 100)), 1, (int)(short.MaxValue * 0.6)); // discreto mesmo no máximo Assert.Equal(4, bytes.Values.Select(Convert.ToBase64String).Distinct().Count()); } [Fact] - public void Every_cue_has_the_same_loudness_and_starts_and_ends_in_silence_without_clicks() + public void Every_cue_has_the_same_loudness_and_starts_and_ends_in_silence() { - var levels = Enum.GetValues().ToDictionary(c => c, c => Rms(ToneSynth.Render(c, 60))); + var levels = Enum.GetValues().ToDictionary(c => c, c => Rms(SoundBank.Render(c, 60))); Assert.InRange(levels.Values.Max() / levels.Values.Min(), 1.0, 1.25); // dentro de ~2 dB: nenhum som "grita" foreach (var cue in Enum.GetValues()) { - var wav = ToneSynth.Render(cue, 100); + var wav = SoundBank.Render(cue, 100); var peak = Peak(wav); Assert.True(Math.Abs((int)BitConverter.ToInt16(wav, 44)) < peak * 0.02, $"{cue}: começa fora do silêncio"); Assert.True(Math.Abs((int)BitConverter.ToInt16(wav, wav.Length - 2)) < peak * 0.02, $"{cue}: termina fora do silêncio"); - var worstStep = 0; - for (var i = 46; i + 1 < wav.Length; i += 2) worstStep = Math.Max(worstStep, Math.Abs(BitConverter.ToInt16(wav, i) - BitConverter.ToInt16(wav, i - 2))); - Assert.True(worstStep < peak * 0.4, $"{cue}: salto brusco entre amostras (estalo)"); } - Assert.True(Rms(ToneSynth.Render(SoundCue.Move, 100)) > Rms(ToneSynth.Render(SoundCue.Move, 25)) * 4); // 25% soa bem mais baixo + Assert.True(Rms(SoundBank.Render(SoundCue.Move, 100)) > Rms(SoundBank.Render(SoundCue.Move, 25)) * 4); // 25% soa bem mais baixo } private static double Rms(byte[] wav) diff --git a/tools/make-sounds.py b/tools/make-sounds.py new file mode 100644 index 00000000..b9c159a8 --- /dev/null +++ b/tools/make-sounds.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Gera assets/sounds/*.wav (os sons do controle, #276/#287) a partir do pacote "Interface Sounds" de Kenney (CC0). + +Só é preciso rodar de novo para trocar um som; o app usa os WAV já prontos. Requer `pip install soundfile numpy`. +Uso: python tools/make-sounds.py assets/sounds + +Cada som: mono, aparado no silêncio, fade de 2 ms (sem estalo), mesma energia (RMS) para todos e pico limitado, PCM 16 bits +a 44,1 kHz. O volume do usuário só abaixa a partir daí (src/ControlFS.Core/Audio/SoundBank.cs). +""" +import sys, os +import numpy as np +import soundfile as sf + +CUES = { # cue do app -> arquivo do pacote de Kenney + "move": "select_002.ogg", + "select": "glass_006.ogg", + "confirm": "confirmation_001.ogg", + "back": "back_004.ogg", +} +TARGET_RMS = 0.10 +PEAK_CAP = 0.80 +RATE = 44100 + +def process(samples, rate): + mono = samples.mean(axis=1) + assert rate == RATE, f"taxa inesperada {rate}" + loud = np.flatnonzero(np.abs(mono) > 0.01) + mono = mono[loud[0]:loud[-1] + 1] + fade = int(RATE * 0.002) + mono[:fade] *= np.linspace(0, 1, fade) + mono[-fade:] *= np.linspace(1, 0, fade) + gain = TARGET_RMS / np.sqrt((mono ** 2).mean()) + gain = min(gain, PEAK_CAP / np.abs(mono).max()) + return mono * gain + +def main(src, dst): + os.makedirs(dst, exist_ok=True) + for cue, name in CUES.items(): + data, rate = sf.read(os.path.join(src, name), always_2d=True) + out = process(data, rate) + sf.write(os.path.join(dst, cue + ".wav"), out, RATE, subtype="PCM_16") + print(f"{cue}.wav <- {name}: {len(out) / RATE * 1000:.0f} ms, rms={np.sqrt((out ** 2).mean()):.3f}, peak={np.abs(out).max():.2f}") + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2])