From 4abd9aa94a9aedaf9897f095bd81cb3891913dab Mon Sep 17 00:00:00 2001 From: nextestudios <47460003+nextestudios@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:52:25 -0300 Subject: [PATCH 1/2] feat: CC0 controller sounds (#287) and hover feedback (#295) Replaces the synthesized cues with four Kenney CC0 sounds (trimmed, equal energy); adds a neutral, mouse-only hover state everywhere something is clickable. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 3 + CHANGELOG.md | 3 + THIRD_PARTY_NOTICES.md | 11 ++- assets/sounds/LICENSE-Kenney-CC0.txt | 22 +++++ assets/sounds/back.wav | Bin 0 -> 5044 bytes assets/sounds/confirm.wav | Bin 0 -> 25072 bytes assets/sounds/move.wav | Bin 0 -> 3534 bytes assets/sounds/select.wav | Bin 0 -> 4902 bytes docs/GUIDE.md | 2 +- docs/GUIDE.pt-BR.md | 2 +- docs/TESTING.md | 5 ++ src/ControlFS.App/Resources/Theme.cs | 22 +++++ src/ControlFS.App/Views/ListHeaderView.cs | 1 + src/ControlFS.App/Views/MainWindow.cs | 2 + src/ControlFS.App/Views/ModalView.cs | 5 ++ .../Views/TutorialOverlayView.cs | 2 + src/ControlFS.Core/Audio/ToneSynth.cs | 81 ------------------ src/ControlFS.Core/ControlFS.Core.csproj | 4 + .../Shell/WindowsSoundPlayer.cs | 4 +- .../Core/ControllerSoundsTests.cs | 19 ++-- tools/make-sounds.py | 46 ++++++++++ 21 files changed, 134 insertions(+), 100 deletions(-) create mode 100644 assets/sounds/LICENSE-Kenney-CC0.txt create mode 100644 assets/sounds/back.wav create mode 100644 assets/sounds/confirm.wav create mode 100644 assets/sounds/move.wav create mode 100644 assets/sounds/select.wav delete mode 100644 src/ControlFS.Core/Audio/ToneSynth.cs create mode 100644 tools/make-sounds.py 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 0000000000000000000000000000000000000000..6239ee8658115eb9de0902c72c58bc123ed26e6c GIT binary patch literal 5044 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 LOf{&Oy9oI|xxLb- literal 0 HcmV?d00001 diff --git a/assets/sounds/confirm.wav b/assets/sounds/confirm.wav new file mode 100644 index 0000000000000000000000000000000000000000..17be3c8b502734f97d2c58f9a44d9f84fb695524 GIT binary patch literal 25072 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 literal 0 HcmV?d00001 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/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/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]) From 2250c57bcab42c8bcc3d5ca5e1b7ef79237c6d69 Mon Sep 17 00:00:00 2001 From: nextestudios <47460003+nextestudios@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:54:05 -0300 Subject: [PATCH 2/2] fix: add the new SoundBank and Hover files Co-Authored-By: Claude Sonnet 5.5 --- src/ControlFS.App/Controls/Hover.cs | 117 ++++++++++++++++++++++++++ src/ControlFS.Core/Audio/SoundBank.cs | 57 +++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/ControlFS.App/Controls/Hover.cs create mode 100644 src/ControlFS.Core/Audio/SoundBank.cs 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.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; + } + } +}