From d319f3076a276e7aec73f5b913dcf162f62c13f8 Mon Sep 17 00:00:00 2001 From: nextestudios <47460003+nextestudios@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:19:14 -0300 Subject: [PATCH] feat: configurable controller sounds (#276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short synthesized cues for move, confirm, back and mark; controller-only, throttled when a direction is held, off by default, volume picker in Configurações. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 2 + CHANGELOG.md | 2 + docs/GUIDE.md | 6 +- docs/GUIDE.pt-BR.md | 6 +- docs/TESTING.md | 5 ++ src/ControlFS.App/Navigation/InputHost.cs | 9 +- src/ControlFS.App/Views/MainWindow.cs | 7 +- .../AppController.Browser.cs | 3 + .../AppController.Choices.cs | 9 ++ src/ControlFS.Core/Audio/ControllerSounds.cs | 67 ++++++++++++++ src/ControlFS.Core/Audio/ToneSynth.cs | 58 ++++++++++++ .../Contracts/ISettingsStore.cs | 6 ++ .../Shell/WindowsSoundPlayer.cs | 46 ++++++++++ .../Core/ControllerSoundsTests.cs | 90 +++++++++++++++++++ 14 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 src/ControlFS.Core/Audio/ControllerSounds.cs create mode 100644 src/ControlFS.Core/Audio/ToneSynth.cs create mode 100644 src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs create mode 100644 tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index f1615fab..4b46504b 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,8 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] ### What's new +- **Controller sounds** (#276): Menu → Configurações → **Sons do controle** (off, low, medium, high, max). Short, soft cues synthesized inside the app, distinct for moving focus, confirming/opening, going back and marking. They play only for controller actions (never the keyboard), don't machine-gun when you hold the D-pad, use the Windows audio output and don't change any button or navigation. Off by default; without a sound device nothing happens. +### What's new - **Automation and `controlfs://` protocol**: support for `controlfs://start`, `controlfs://stop`, and `controlfs://show` links, as well as command-line arguments (`--start`, `--stop`, `--show`, `--close`), enabling seamless external integration with game launchers and frontends (such as Console Mode), Stream Deck buttons, and automation scripts. Invoking `controlfs://start` when the app is already open restores and brings the window to the foreground without launching duplicate instances; `controlfs://stop` cleanly exits the app; with copies or other operations running it doesn't cancel them by itself and shows the exit confirmation. The protocol is registered by the installer; the portable version writes nothing to the registry (the command-line flags work there). After an update, the app relaunched by the installer waits for the old instance to leave. ### Fixes diff --git a/CHANGELOG.md b/CHANGELOG.md index 645c011d..a1a27694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] ### Novidades +- **Sons do controle** (#276): Menu → Configurações → **Sons do controle** (desligados, baixo, médio, alto, máximo). Toques curtos e suaves, desenhados no próprio app, distintos para mover o foco, confirmar/abrir, voltar e marcar. Tocam só nas ações do controle (nunca do teclado), não repetem feito metralhadora ao segurar o direcional, usam a saída de áudio do Windows e não mudam nenhum botão nem a navegação. Vem desligado; sem placa de som, nada acontece. +### Novidades - **Automação e protocolo `controlfs://`**: suporte aos links `controlfs://start`, `controlfs://stop` e `controlfs://show`, bem como argumentos de linha de comando (`--start`, `--stop`, `--show`, `--close`), facilitando a integração externa com launchers e frontends (como o Console Mode), botões de Stream Deck e scripts. Acionar `controlfs://start` quando o app já está aberto restaura e traz a janela para o primeiro plano sem abrir instâncias duplicadas; `controlfs://stop` encerra o app de forma limpa; havendo cópias ou outras operações em andamento, ele não as cancela sozinho e mostra a confirmação de saída. O protocolo é registrado pelo instalador; a versão portátil não escreve nada no registro (as opções de linha de comando funcionam nela). Depois de uma atualização, o app reaberto pelo instalador espera a instância antiga sair. ### Correções diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 80292d3d..9989827f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -45,7 +45,7 @@ Item actions (North) and the app Menu (Start) open with the most used actions as In dialogs, the footer says what each button does. With the focus on the back option itself (**Cancelar**, **Fechar**), the footer shows the next option on Right instead of repeating the same thing on both buttons. -**Option pickers:** a setting with several values that the tile or row doesn't show (**Exibição**, **Densidade da lista**, **Tema**, **Cor de destaque**, **Ordenar por**, **Confirmar com**, **Legendas**, **Fluidez**) opens a picker listing every choice, each with a short explanation, the current one marked with a filled radio icon and the word "atual" and the focus on it. South/Enter or a click on a choice applies it and returns to Configurações on the same setting; Back (or Esc) closes the picker without changing anything. Simple on/off settings and settings that open their own screen stay one press. The same picker is used for the size and date filters of a search and the mode of batch rename. All settings live in Menu → **Configurações** (settings), grouped under **Exibição** (view, density, details panel, sort, hidden items), **Busca e privacidade** (search in subfolders, recents, keyboard suggestions), **Controles** (confirm button, button labels, Fluidez (controller read rate), Leve em segundo plano (light in the background), gyro aiming (experimental), active controller, controller test, controllers without a profile) and **ControlFS** (updates). In each group, short settings are **tiles** in a grid with the icon, a short name and the current value (e.g. **Ocultos** · escondidos, hidden items · hidden). Values are states, never verbs, and on/off settings say **ligado**/**desligado** (on/off); the same setting has the same name and value everywhere. The line under the focused tile explains what the setting does instead of repeating its value. Settings with long descriptions or that open another screen stay in a list below. In the grid, Left/Right move between tiles, Down from the last row continues to the group's list (and from there to the next group's grid) and Up goes back to the tile you came from. Every window of the app keeps its size while you use it: the panel never widens, narrows, grows or shrinks as focus moves, values change, a message appears or you switch options (for example ZIP, TAR.GZ and 7z in the Compress window are the same size); long texts wrap inside it, and the description area is as tall as the longest description. Changing a setting keeps Configurações open with the new value and focus on the same setting, so you can change several in a row; Back closes it. +**Option pickers:** a setting with several values that the tile or row doesn't show (**Exibição**, **Densidade da lista**, **Tema**, **Cor de destaque**, **Ordenar por**, **Confirmar com**, **Legendas**, **Fluidez**) opens a picker listing every choice, each with a short explanation, the current one marked with a filled radio icon and the word "atual" and the focus on it. South/Enter or a click on a choice applies it and returns to Configurações on the same setting; Back (or Esc) closes the picker without changing anything. Simple on/off settings and settings that open their own screen stay one press. The same picker is used for the size and date filters of a search and the mode of batch rename. All settings live in Menu → **Configurações** (settings), grouped under **Exibição** (view, density, details panel, sort, hidden items), **Busca e privacidade** (search in subfolders, recents, keyboard suggestions), **Controles** (confirm button, button labels, Fluidez (controller read rate), Sons do controle (controller sounds), Leve em segundo plano (light in the background), gyro aiming (experimental), active controller, controller test, controllers without a profile) and **ControlFS** (updates). In each group, short settings are **tiles** in a grid with the icon, a short name and the current value (e.g. **Ocultos** · escondidos, hidden items · hidden). Values are states, never verbs, and on/off settings say **ligado**/**desligado** (on/off); the same setting has the same name and value everywhere. The line under the focused tile explains what the setting does instead of repeating its value. Settings with long descriptions or that open another screen stay in a list below. In the grid, Left/Right move between tiles, Down from the last row continues to the group's list (and from there to the next group's grid) and Up goes back to the tile you came from. Every window of the app keeps its size while you use it: the panel never widens, narrows, grows or shrinks as focus moves, values change, a message appears or you switch options (for example ZIP, TAR.GZ and 7z in the Compress window are the same size); long texts wrap inside it, and the description area is as tall as the longest description. Changing a setting keeps Configurações open with the new value and focus on the same setting, so you can change several in a row; Back closes it. ### Controller test @@ -307,6 +307,10 @@ ControlFS can be opened, brought to the front, or closed from external tools, sc The `controlfs://` scheme is registered in Windows by the installer (and re-registered on startup if the install folder moved). The portable version writes nothing to the registry, so `controlfs://` links need the installed version; the command-line flags work everywhere. Only one instance of ControlFS runs per user session; launching a second instance signals the active window and exits immediately. +## 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. Choose **desligados** (off, the default), **baixo**, **médio**, **alto** or **máximo**. 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). + ## Screen readers With Narrator (or another UI Automation screen reader) on, the app announces where the focus is and the focused item as you move with the controller or keyboard: the home screen, folder, menu, dialog or on-screen keyboard when you enter it, then just the item as you move (name, type, size and position such as "3 of 20"). States are spoken in words: marked, cut, blocked (with the reason), password-protected, unavailable (with the reason). Notices (the pop-up in the bottom-right corner) and the line above the list are read without moving the focus. Nothing depends on sound, vibration or color alone. diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 26d59c55..38ebf0a0 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -45,7 +45,7 @@ As ações do item (Norte) e o Menu do app (Start) abrem com as ações mais usa Nos diálogos, o rodapé diz o que cada botão faz. Com o foco na própria opção de voltar (**Cancelar**, **Fechar**), o rodapé mostra a próxima opção na seta para a direita em vez de repetir a mesma coisa nos dois botões. -**Seletor de opções:** um ajuste com vários valores que o bloco ou a linha não mostram (**Exibição**, **Densidade da lista**, **Tema**, **Cor de destaque**, **Ordenar por**, **Confirmar com**, **Legendas**, **Fluidez**) abre um seletor com todas as escolhas, cada uma com uma explicação curta, a atual marcada com um botão de opção cheio e a palavra "atual" e o foco nela. Sul/Enter ou um clique numa escolha a aplica e volta a Configurações no mesmo ajuste; Voltar (ou Esc) fecha o seletor sem mudar nada. Ligar/desligar simples e ajustes que abrem a própria tela continuam de um toque. O mesmo seletor vale para o tamanho e a data dos filtros da busca e para o modo de Renomear em lote. Todos os ajustes ficam em Menu → **Configurações**, em grupos: **Exibição** (lista/grade, densidade, painel de detalhes, ordenação, itens ocultos), **Busca e privacidade** (busca em subpastas, recentes, sugestões do teclado), **Controles** (botão de confirmar, legendas, Fluidez, Leve em segundo plano, mira por giroscópio (experimental), controle ativo, teste de controles, controles sem perfil) e **ControlFS** (atualizações). Em cada grupo, os ajustes curtos são **blocos** numa grade, com o ícone, o nome curto e o valor atual (ex.: **Ocultos** · escondidos). Os valores são estados, nunca verbos, e ajustes de ligar/desligar dizem **ligado**/**desligado**; o mesmo ajuste tem o mesmo nome e valor em qualquer tela. A linha sob o bloco focado explica o que o ajuste faz em vez de repetir o valor. Os que têm descrição longa ou abrem outra tela ficam numa lista abaixo. Na grade, esquerda/direita andam entre os blocos, baixo na última linha segue para a lista do grupo (e dela para a grade do grupo seguinte) e cima volta ao bloco de onde você veio. Toda janela do app mantém o tamanho enquanto você a usa: o painel não alarga, estreita, cresce nem encolhe quando o foco se move, um valor muda, aparece um aviso ou você troca de opção (por exemplo, ZIP, TAR.GZ e 7z na janela Compactar têm o mesmo tamanho); textos longos quebram linha dentro dele, e a área de descrição tem a altura da descrição mais longa. Mudar um ajuste mantém Configurações aberto com o valor novo e o foco no mesmo ajuste, para mudar vários seguidos; Voltar fecha. +**Seletor de opções:** um ajuste com vários valores que o bloco ou a linha não mostram (**Exibição**, **Densidade da lista**, **Tema**, **Cor de destaque**, **Ordenar por**, **Confirmar com**, **Legendas**, **Fluidez**) abre um seletor com todas as escolhas, cada uma com uma explicação curta, a atual marcada com um botão de opção cheio e a palavra "atual" e o foco nela. Sul/Enter ou um clique numa escolha a aplica e volta a Configurações no mesmo ajuste; Voltar (ou Esc) fecha o seletor sem mudar nada. Ligar/desligar simples e ajustes que abrem a própria tela continuam de um toque. O mesmo seletor vale para o tamanho e a data dos filtros da busca e para o modo de Renomear em lote. Todos os ajustes ficam em Menu → **Configurações**, em grupos: **Exibição** (lista/grade, densidade, painel de detalhes, ordenação, itens ocultos), **Busca e privacidade** (busca em subpastas, recentes, sugestões do teclado), **Controles** (botão de confirmar, legendas, Fluidez, Sons do controle, Leve em segundo plano, mira por giroscópio (experimental), controle ativo, teste de controles, controles sem perfil) e **ControlFS** (atualizações). Em cada grupo, os ajustes curtos são **blocos** numa grade, com o ícone, o nome curto e o valor atual (ex.: **Ocultos** · escondidos). Os valores são estados, nunca verbos, e ajustes de ligar/desligar dizem **ligado**/**desligado**; o mesmo ajuste tem o mesmo nome e valor em qualquer tela. A linha sob o bloco focado explica o que o ajuste faz em vez de repetir o valor. Os que têm descrição longa ou abrem outra tela ficam numa lista abaixo. Na grade, esquerda/direita andam entre os blocos, baixo na última linha segue para a lista do grupo (e dela para a grade do grupo seguinte) e cima volta ao bloco de onde você veio. Toda janela do app mantém o tamanho enquanto você a usa: o painel não alarga, estreita, cresce nem encolhe quando o foco se move, um valor muda, aparece um aviso ou você troca de opção (por exemplo, ZIP, TAR.GZ e 7z na janela Compactar têm o mesmo tamanho); textos longos quebram linha dentro dele, e a área de descrição tem a altura da descrição mais longa. Mudar um ajuste mantém Configurações aberto com o valor novo e o foco no mesmo ajuste, para mudar vários seguidos; Voltar fecha. ### Teste de controles @@ -307,6 +307,10 @@ O ControlFS pode ser aberto, trazido para a frente ou encerrado por ferramentas O esquema `controlfs://` é registrado no Windows pelo instalador (e de novo ao iniciar, se a pasta de instalação mudou). A versão portátil não escreve nada no registro, então os links `controlfs://` pedem a versão instalada; as opções de linha de comando funcionam em qualquer uma. Apenas uma instância do ControlFS roda por sessão de usuário; abrir uma segunda chamada sinaliza a janela ativa e encerra o novo processo imediatamente. +## 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. Escolha **desligados** (o padrão), **baixo**, **médio**, **alto** ou **máximo**. 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). + ## Leitores de tela Com o Narrador (ou outro leitor de tela com UI Automation) ligado, o app anuncia onde está o foco e o item focado enquanto você anda com o controle ou o teclado: a tela inicial, a pasta, o menu, o diálogo ou o teclado virtual ao entrar, depois só o item a cada movimento (nome, tipo, tamanho e posição, como "3 de 20"). Estados são ditos por extenso: marcado, recortado, bloqueado (com o motivo), com senha, indisponível (com o motivo). Os avisos (no canto inferior direito) e a linha acima da lista são lidos sem mover o foco. Nada depende só de som, vibração ou cor. diff --git a/docs/TESTING.md b/docs/TESTING.md index 389890de..6fc9b60c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -856,3 +856,8 @@ Só a CI (WinRAR 7.23 pelo winget, arquivo criado pelo Rar.exe real e extraído - [ ] Com o WinRAR instalado (outra versão que a da CI, ex.: 6.x e 7.x mais novo): marcar itens → Compactar → RAR → o `.rar` abre no WinRAR e no ControlFS com o mesmo conteúdo; nome existente vira "(2)". - [ ] Criar um RAR grande e cancelar no meio pelo controle: o `.rar` parcial some, o Rar.exe não fica no Gerenciador de Tarefas. - [ ] Arquivo em uso entre os itens: resultado "concluído com avisos" (o WinRAR devolve o código 1) e a mensagem pede para conferir. + +## Sons do controle (#276) — não validado em hardware +- [ ] Menu → Configurações → Sons do controle → baixo/médio/alto: mover o foco, confirmar, voltar e marcar tocam sons diferentes e discretos; o volume muda entre as opções; "desligados" silencia tudo. +- [ ] Segurar o direcional numa lista longa: o tique não vira chiado nem atrasa o foco. O teclado (setas, Enter, Esc) não toca nada. +- [ ] Com fones/alto-falantes diferentes ou sem saída de áudio: segue a saída padrão do Windows; sem dispositivo, o app funciona igual. diff --git a/src/ControlFS.App/Navigation/InputHost.cs b/src/ControlFS.App/Navigation/InputHost.cs index 24d4709f..0c37454c 100644 --- a/src/ControlFS.App/Navigation/InputHost.cs +++ b/src/ControlFS.App/Navigation/InputHost.cs @@ -39,11 +39,16 @@ public sealed class InputHost : IInputSink, IRawControllerSource, IControllerDia private readonly Dictionary _scrollers = []; private readonly Dictionary _gyros = []; - public InputHost(AppController app, DispatcherQueue queue) + public InputHost(AppController app, DispatcherQueue queue, Core.Audio.ControllerSounds? sounds = null) { _app = app; _queue = queue; - Router = new InputRouter(new ActionMap(app.Settings.Convention), InputSettings.Default, app.Handle); + // Só as ações do controle passam pelo som (o teclado chama app.Handle direto). O som é o primeiro passo e nunca bloqueia. + Router = new InputRouter(new ActionMap(app.Settings.Convention), InputSettings.Default, sounds is null ? app.Handle : action => + { + sounds.OnAction(action); + app.Handle(action); + }); Router.RepeatPolicy = app.IsRepeatableInContext; Router.ActiveDeviceChanged += _ => { diff --git a/src/ControlFS.App/Views/MainWindow.cs b/src/ControlFS.App/Views/MainWindow.cs index bc5bb89e..4262493e 100644 --- a/src/ControlFS.App/Views/MainWindow.cs +++ b/src/ControlFS.App/Views/MainWindow.cs @@ -37,6 +37,7 @@ public sealed class MainWindow : Window { private readonly AppController _app; private readonly InputHost _input; + private readonly WindowsSoundPlayer? _soundPlayer; private readonly GitHubReleaseUpdateService? _updates; /// Ouvinte de instância única e automação (sinais Show e Close). Null nas capturas. private readonly SingleInstanceListener? _singleInstanceListener; @@ -160,7 +161,10 @@ internal MainWindow(string? dataDirectory) _app.AttachPhoneLink(_phone); } var inputStarted = startup.ElapsedMilliseconds; - _input = new InputHost(_app, DispatcherQueue); + if (dataDirectory is null) _soundPlayer = new WindowsSoundPlayer(); + var clock = System.Diagnostics.Stopwatch.StartNew(); + _input = new InputHost(_app, DispatcherQueue, _soundPlayer is null ? null + : new Core.Audio.ControllerSounds(_soundPlayer, () => _app.Settings.ControllerSoundVolume, () => clock.Elapsed)); if (dataDirectory is null) { _background = new BackgroundMode(_app, DispatcherQueue, _drives); @@ -265,6 +269,7 @@ internal MainWindow(string? dataDirectory) _app.ReleaseMediaForShutdown(); _app.PrepareShutdown(); // instala em silêncio uma atualização verificada, se o usuário deixou ligado _input.Dispose(); + _soundPlayer?.Dispose(); _phone?.Dispose(); // para de escutar e avisa o celular _updates?.Dispose(); _iconProvider.Dispose(); diff --git a/src/ControlFS.Application/AppController.Browser.cs b/src/ControlFS.Application/AppController.Browser.cs index 3a7c81f8..0bcb4707 100644 --- a/src/ControlFS.Application/AppController.Browser.cs +++ b/src/ControlFS.Application/AppController.Browser.cs @@ -679,6 +679,9 @@ private List SettingsItems() ChoiceRow("Fluidez", Settings.SyncInputToDisplay, SmoothnessChoices, on => UpdateSettings(s => s with { SyncInputToDisplay = on }), ActionIcon.Settings, section: controls, placement: tile, shortLabel: "Fluidez", context: settingsContext, detail: "Máxima lê o controle ~125 vezes por segundo (o bastante para telas de 120 Hz). Economia gasta menos bateria em portáteis."), + ChoiceRow("Sons do controle", Settings.ControllerSoundVolume, SoundChoices, volume => UpdateSettings(s => s with { ControllerSoundVolume = volume }), ActionIcon.Audio, section: controls, + placement: tile, shortLabel: "Sons", context: settingsContext, + detail: "Toques curtos ao mover o foco, confirmar, voltar e marcar, só com o controle (nunca com o teclado). Respeita a saída de áudio do Windows."), new($"Leve em segundo plano: {OnOff(Settings.LightInBackground)}", () => UpdateSettings(s => s with { LightInBackground = !s.LightInBackground }), Detail: "Com a janela minimizada ou atrás de um jogo, o ControlFS cede o processador, para de consultar unidades e devolve memória ao Windows. Cópias e extrações continuam, mais devagar.", diff --git a/src/ControlFS.Application/AppController.Choices.cs b/src/ControlFS.Application/AppController.Choices.cs index 36814b25..f0dd0866 100644 --- a/src/ControlFS.Application/AppController.Choices.cs +++ b/src/ControlFS.Application/AppController.Choices.cs @@ -124,6 +124,15 @@ private void ApplyChoice(T value, Action onPick) new(false, "economia", "Lê o controle menos vezes: gasta menos bateria em portáteis."), ]; + private static IReadOnlyList> SoundChoices { get; } = + [ + new(0, "desligados", "Sem sons: o controle só se vê, não se ouve."), + new(25, "baixo", "Toques discretos ao mover, confirmar, voltar e marcar."), + new(50, "médio", "Toques discretos ao mover, confirmar, voltar e marcar."), + new(75, "alto", "Toques discretos ao mover, confirmar, voltar e marcar."), + new(100, "máximo", "O volume mais alto dos toques (ainda curtos e suaves)."), + ]; + private void SetView(Core.Contracts.ViewMode view) { if (Settings.View == view) return; diff --git a/src/ControlFS.Core/Audio/ControllerSounds.cs b/src/ControlFS.Core/Audio/ControllerSounds.cs new file mode 100644 index 00000000..3349b525 --- /dev/null +++ b/src/ControlFS.Core/Audio/ControllerSounds.cs @@ -0,0 +1,67 @@ +using ControlFS.Core.Actions; + +namespace ControlFS.Core.Audio; + +/// Os sons do controle (#276): curtos e distintos, para ouvir o que aconteceu sem olhar a tela. +public enum SoundCue +{ + /// O foco andou (direcional, páginas, troca de região ou de painel). + Move, + + /// Confirmou ou abriu (Confirmar, menus, busca, trocar exibição). + Confirm, + + /// Voltou ou cancelou. + Back, + + /// Marcou ou desmarcou um item. + Select, +} + +/// Toca um som pronto, sem nunca travar quem chama (sem placa de som, não faz nada). +public interface ISoundPlayer +{ + /// 1 a 100. + void Play(SoundCue cue, int volume); +} + +/// +/// Liga as ações vindas do controle aos sons (#276). Só o controle chama (o teclado não toca), nunca muda o que a ação faz e +/// nunca atrasa o controle: tocar é assíncrono e falha em silêncio. Segurar o direcional não vira metralhadora: um mesmo +/// som não repete antes do intervalo mínimo. Volume 0 (padrão) = desligado: nada é tocado nem preparado. +/// +public sealed class ControllerSounds(ISoundPlayer player, Func volume, Func clock) +{ + private readonly Dictionary _last = []; + + /// Intervalo mínimo entre dois sons iguais: o movimento repete rápido ao segurar, então é o mais espaçado. + public static TimeSpan MinGap(SoundCue cue) => cue == SoundCue.Move ? TimeSpan.FromMilliseconds(80) : TimeSpan.FromMilliseconds(120); + + /// O som de uma ação, ou null quando ela é silenciosa (rolagem do analógico, troca de painel sem efeito…). + public static SoundCue? CueFor(InputAction action) => action switch + { + InputAction.NavigateUp or InputAction.NavigateDown or InputAction.NavigateLeft or InputAction.NavigateRight + or InputAction.PageUp or InputAction.PageDown or InputAction.PreviousRegion or InputAction.NextRegion or InputAction.SwitchPane => SoundCue.Move, + InputAction.Confirm or InputAction.OpenContextMenu or InputAction.OpenAppMenu or InputAction.Search or InputAction.ChangeView => SoundCue.Confirm, + InputAction.Back => SoundCue.Back, + InputAction.ToggleSelection => SoundCue.Select, + _ => null, // rolagem contínua do analógico e o que não tiver som próprio + }; + + public void OnAction(InputAction action) + { + var level = Math.Clamp(volume(), 0, 100); + if (level == 0 || CueFor(action) is not { } cue) return; + var now = clock(); + if (_last.TryGetValue(cue, out var previous) && now - previous < MinGap(cue)) return; + _last[cue] = now; + try + { + player.Play(cue, level); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or System.Runtime.InteropServices.ExternalException) + { + // Som é só um extra: qualquer falha do sistema de áudio fica em silêncio. + } + } +} diff --git a/src/ControlFS.Core/Audio/ToneSynth.cs b/src/ControlFS.Core/Audio/ToneSynth.cs new file mode 100644 index 00000000..50d46a4c --- /dev/null +++ b/src/ControlFS.Core/Audio/ToneSynth.cs @@ -0,0 +1,58 @@ +namespace ControlFS.Core.Audio; + +/// +/// Os sons do controle, sintetizados aqui (#276): tons senoidais curtos com ataque e queda suaves, sem nenhum arquivo de +/// terceiros (os sons da interface do PS5 são só uma referência de sensação, não foram copiados). Saem como WAV PCM de +/// 16 bits, mono, 44,1 kHz, prontos para tocar da memória. +/// +public static class ToneSynth +{ + public const int SampleRate = 44100; + + /// Cada som: notas (frequência em Hz, duração em ms), sem pausa entre elas. + private static (double Hz, double Ms)[] Notes(SoundCue cue) => cue switch + { + SoundCue.Move => [(1320, 28)], + SoundCue.Select => [(880, 55)], + SoundCue.Confirm => [(660, 55), (990, 85)], // sobe + SoundCue.Back => [(660, 55), (440, 85)], // desce + _ => [(880, 40)], + }; + + /// WAV do som no volume de 1 a 100 (o pico fica em até ~45% do máximo: discreto mesmo no volume 100). + public static byte[] Render(SoundCue cue, int volume) + { + var gain = 0.45 * Math.Clamp(volume, 1, 100) / 100.0; + var samples = new List(); + foreach (var (hz, ms) in Notes(cue)) + { + var count = (int)(SampleRate * ms / 1000.0); + var fade = Math.Min(count / 2, (int)(SampleRate * 0.006)); // 6 ms de entrada e de saída: sem estalo + for (var i = 0; i < count; i++) + { + var envelope = i < fade ? (double)i / fade : i >= count - fade ? (double)(count - 1 - i) / fade : 1.0; + // Queda suave ao longo do som: o fim é mais fraco que o começo, como um toque. + var decay = 1.0 - (0.6 * i / count); + samples.Add((short)Math.Round(Math.Sin(2 * Math.PI * hz * i / SampleRate) * envelope * decay * gain * short.MaxValue)); + } + } + using var memory = new MemoryStream(); + using var writer = new BinaryWriter(memory); + var dataBytes = samples.Count * 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 sample in samples) writer.Write(sample); + writer.Flush(); + return memory.ToArray(); + } +} diff --git a/src/ControlFS.Core/Contracts/ISettingsStore.cs b/src/ControlFS.Core/Contracts/ISettingsStore.cs index 311bcc00..2a3149bc 100644 --- a/src/ControlFS.Core/Contracts/ISettingsStore.cs +++ b/src/ControlFS.Core/Contracts/ISettingsStore.cs @@ -96,6 +96,12 @@ public sealed record AppSettings /// public bool GyroKeyboard { get; init; } + /// + /// Sons do controle (#276): volume de 0 a 100. 0 = desligado (padrão: o ControlFS não faz barulho sem você pedir). Os sons + /// só tocam nas ações vindas do controle, nunca do teclado. + /// + public int ControllerSoundVolume { get; init; } + /// Status do Git (#75): ramo e marcas de modificado/novo em pastas de repositórios. Desligado por padrão. public bool ShowGitStatus { get; init; } diff --git a/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs b/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs new file mode 100644 index 00000000..c9c2b631 --- /dev/null +++ b/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs @@ -0,0 +1,46 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using ControlFS.Core.Audio; + +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 +/// nativa enquanto o app roda. Sem dispositivo de som, PlaySound só devolve falso: nada quebra. +/// +public sealed partial class WindowsSoundPlayer : ISoundPlayer, IDisposable +{ + private const uint SndAsync = 0x1, SndNoDefault = 0x2, SndMemory = 0x4; + private readonly Dictionary<(SoundCue, int), nint> _sounds = []; + private bool _disposed; + + public void Play(SoundCue cue, int volume) + { + if (!OperatingSystem.IsWindows() || _disposed) return; + // Volumes em degraus de 5: poucos WAVs diferentes na memória. + var level = Math.Clamp((volume + 4) / 5 * 5, 5, 100); + if (!_sounds.TryGetValue((cue, level), out var memory)) + { + var wav = ToneSynth.Render(cue, level); + memory = Marshal.AllocHGlobal(wav.Length); + Marshal.Copy(wav, 0, memory, wav.Length); + _sounds[(cue, level)] = memory; + } + _ = PlaySoundW(memory, 0, SndMemory | SndAsync | SndNoDefault); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (OperatingSystem.IsWindows()) _ = PlaySoundW(0, 0, 0); // para o som em curso antes de liberar a memória + foreach (var memory in _sounds.Values) Marshal.FreeHGlobal(memory); + _sounds.Clear(); + } + + [LibraryImport("winmm.dll", EntryPoint = "PlaySoundW")] + [return: MarshalAs(UnmanagedType.Bool)] + [SupportedOSPlatform("windows")] + private static partial bool PlaySoundW(nint pszSound, nint hmod, uint fdwSound); +} diff --git a/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs b/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs new file mode 100644 index 00000000..bea115d6 --- /dev/null +++ b/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs @@ -0,0 +1,90 @@ +using ControlFS.Core.Actions; +using ControlFS.Core.Audio; +using Xunit; + +namespace ControlFS.UnitTests.Core; + +public sealed class ControllerSoundsTests +{ + private sealed class Recorder : ISoundPlayer + { + public List<(SoundCue Cue, int Volume)> Played { get; } = []; + public void Play(SoundCue cue, int volume) => Played.Add((cue, volume)); + } + + [Fact] + public void Volume_zero_is_silent_and_the_default_setting_is_off() + { + var player = new Recorder(); + var sounds = new ControllerSounds(player, () => 0, () => TimeSpan.Zero); + sounds.OnAction(InputAction.Confirm); + Assert.Empty(player.Played); + Assert.Equal(0, new ControlFS.Core.Contracts.AppSettings().ControllerSoundVolume); + } + + [Fact] + public void Each_kind_of_action_has_its_own_cue_and_the_analog_scroll_is_silent() + { + Assert.Equal(SoundCue.Move, ControllerSounds.CueFor(InputAction.NavigateDown)); + Assert.Equal(SoundCue.Move, ControllerSounds.CueFor(InputAction.PageUp)); + Assert.Equal(SoundCue.Confirm, ControllerSounds.CueFor(InputAction.Confirm)); + Assert.Equal(SoundCue.Back, ControllerSounds.CueFor(InputAction.Back)); + Assert.Equal(SoundCue.Select, ControllerSounds.CueFor(InputAction.ToggleSelection)); + Assert.All([InputAction.ScrollUp, InputAction.ScrollDown, InputAction.ScrollLeft, InputAction.ScrollRight], a => Assert.Null(ControllerSounds.CueFor(a))); + } + + [Fact] + public void Holding_the_dpad_does_not_machine_gun_the_same_cue_but_other_cues_still_play() + { + var player = new Recorder(); + var now = TimeSpan.Zero; + var sounds = new ControllerSounds(player, () => 60, () => now); + for (var i = 0; i < 40; i++) // 40 repetições a cada 10 ms = 400 ms segurando + { + sounds.OnAction(InputAction.NavigateDown); + now += TimeSpan.FromMilliseconds(10); + } + Assert.InRange(player.Played.Count, 4, 6); // ~1 a cada 80 ms + Assert.All(player.Played, p => Assert.Equal((SoundCue.Move, 60), p)); + + sounds.OnAction(InputAction.Confirm); // outro som passa na hora + Assert.Equal(SoundCue.Confirm, player.Played[^1].Cue); + } + + [Fact] + public void A_failing_audio_system_never_breaks_the_action() + { + var sounds = new ControllerSounds(new Throwing(), () => 50, () => TimeSpan.Zero); + sounds.OnAction(InputAction.Confirm); // não lança + } + + private sealed class Throwing : ISoundPlayer + { + public void Play(SoundCue cue, int volume) => throw new InvalidOperationException("sem dispositivo"); + } + + [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)); + 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.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.5)); // discreto mesmo no máximo + Assert.Equal(4, bytes.Values.Select(Convert.ToBase64String).Distinct().Count()); + } + + private static int Peak(byte[] wav) + { + var peak = 0; + for (var i = 44; i + 1 < wav.Length; i += 2) peak = Math.Max(peak, Math.Abs((int)BitConverter.ToInt16(wav, i))); + return peak; + } +}