diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 97ca113..e2811f9 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -6,6 +6,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ### Fixes - **Xbox controllers no longer ask to be configured** (#284): when an Xbox controller also showed up as a "joystick without a profile" (a second instance, a receiver or a generic driver), the app said "Controle sem perfil… hold any button" and opened the mapping wizard even though the controller already worked. Xbox controllers (Microsoft vendor, or "Xbox"/"XInput" in the name) now never trigger the notice or the wizard; real generic joysticks can still be configured. ### Changes +- **Controller sounds on by default and nicer** (#286, #287): a fresh install (or no saved choice) now has the sounds on at medium volume; anyone who picked "desligados" (off) stays silent, and the choice is kept across restarts and updates. The four cues were redone: glassy timbre (a fundamental with soft harmonics), rounded attack, gentle decay, all at the same loudness and without clicks; "baixo" (low) is now really low. They are still synthesized in the app (no third-party files; origin and license in THIRD_PARTY_NOTICES.md). - **Git status left Configurações** (#284): the option, the "Status do Git: ligado/desligado" notice, the branch in the header and the "Git: modificado" marks are gone from the interface. ### What's new - **Checks and updates by itself on open**: ControlFS now looks for a new version **every time it opens** (before, at most once a day) and, in the installed build, if a verified version exists (signed manifest, SHA-256 and size) and you haven't started using the app and nothing is running, it **downloads, installs and reopens already updated**, without asking. If you already started using it, the usual notice stays (Install and restart / Later). Turn it off in Menu → Configurações → Atualizações → **Atualizar sozinho ao abrir**. The portable build only notifies. It never runs in CI or with `--no-auto-update`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a2c001..3db11a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ### Correções - **Controle Xbox não pede mais para ser configurado** (#284): quando um controle Xbox aparecia também como "joystick sem perfil" (segunda instância, receptor ou driver genérico), o app avisava "Controle sem perfil… segure qualquer botão" e abria o assistente de configuração, mesmo com o controle já funcionando. Agora controles Xbox (fabricante Microsoft, ou "Xbox"/"XInput" no nome) nunca disparam o aviso nem o assistente; joysticks genéricos de verdade continuam podendo ser configurados. ### Mudanças +- **Sons do controle ligados por padrão e mais bonitos** (#286, #287): numa instalação nova (ou sem escolha salva) os sons já vêm ligados em volume médio; quem escolheu "desligados" continua sem som, e a escolha fica salva ao reabrir e ao atualizar. Os quatro sons foram refeitos: timbre de vidro (fundamental com harmônicos suaves), ataque arredondado, queda suave, todos com o mesmo volume e sem estalos; o volume "baixo" agora é realmente baixo. Continuam sintetizados no app (sem arquivos de terceiros; a origem e a licença estão em THIRD_PARTY_NOTICES.md). - **Status do Git saiu das Configurações** (#284): a opção, o aviso "Status do Git: ligado/desligado", o ramo no cabeçalho e as marcas "Git: modificado" deixaram de existir na interface. ### Novidades - **Verifica e se atualiza sozinho ao abrir**: o ControlFS agora procura versão nova **a cada vez que abre** (antes, no máximo uma vez por dia) e, na versão instalada, se há uma versão verificada (manifesto assinado, SHA-256 e tamanho) e você ainda não começou a usar o app e nada está em andamento, **baixa, instala e reabre já atualizado**, sem perguntar. Se você já começou a usar, fica o aviso de sempre (Instalar e reiniciar / Depois). Desligue em Menu → Configurações → Atualizações → **Atualizar sozinho ao abrir**. A versão portátil só avisa. Nunca roda em CI nem com `--no-auto-update`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 03962fa..99e2693 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -98,4 +98,12 @@ https://github.com/lippdev/consolemode) são os logos de outros aplicativos da e mantenedor só para a tela "Mais da equipe". São marcas e artes dos respectivos projetos: NÃO estão cobertas pela AGPL-3.0-only deste código e não podem ser reutilizadas sem permissão dos donos. +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. + O Windows e seus serviços não fazem parte deste código aberto. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 7da5ecf..6c9412f 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. 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). +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). ## Screen readers diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 32ec010..46416d6 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. 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). +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). ## Leitores de tela diff --git a/docs/TESTING.md b/docs/TESTING.md index a578ae2..ae813c2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -861,3 +861,8 @@ Só a CI (WinRAR 7.23 pelo winget, arquivo criado pelo Rar.exe real e extraído - [ ] Instalado numa versão antiga, com internet: abrir o app sem tocar em nada. Ele baixa a versão nova, fecha e reabre sozinho já atualizada (Menu → Sobre mostra a nova). - [ ] Abrir e mexer no controle antes de terminar o download: aparece "Atualização pronta" (Instalar e reiniciar / Depois), sem reiniciar sozinho. - [ ] Menu → Configurações → Atualizações → "Atualizar sozinho ao abrir: não": só o aviso. Com uma cópia em andamento nunca reinicia. + +## Sons do controle: qualidade e padrão (#286, #287) — não validado em hardware +- [ ] 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. diff --git a/src/ControlFS.App/Views/MainWindow.cs b/src/ControlFS.App/Views/MainWindow.cs index afd91cf..86b7d01 100644 --- a/src/ControlFS.App/Views/MainWindow.cs +++ b/src/ControlFS.App/Views/MainWindow.cs @@ -167,7 +167,7 @@ internal MainWindow(string? dataDirectory) 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)); + : new Core.Audio.ControllerSounds(_soundPlayer, () => _app.Settings.EffectiveControllerSoundVolume, () => clock.Elapsed)); if (dataDirectory is null) { _background = new BackgroundMode(_app, DispatcherQueue, _drives); diff --git a/src/ControlFS.Application/AppController.Browser.cs b/src/ControlFS.Application/AppController.Browser.cs index da717f7..9ff80e2 100644 --- a/src/ControlFS.Application/AppController.Browser.cs +++ b/src/ControlFS.Application/AppController.Browser.cs @@ -676,7 +676,7 @@ 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, + ChoiceRow("Sons do controle", NearestSoundChoice(Settings.EffectiveControllerSoundVolume), SoundChoices, volume => UpdateSettings(s => s with { ControllerSoundLevel = 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)}", diff --git a/src/ControlFS.Application/AppController.Choices.cs b/src/ControlFS.Application/AppController.Choices.cs index f0dd086..7e67228 100644 --- a/src/ControlFS.Application/AppController.Choices.cs +++ b/src/ControlFS.Application/AppController.Choices.cs @@ -133,6 +133,9 @@ private void ApplyChoice(T value, Action onPick) new(100, "máximo", "O volume mais alto dos toques (ainda curtos e suaves)."), ]; + /// A alternativa da lista mais próxima do volume salvo (o seletor marca uma delas). + private static int NearestSoundChoice(int volume) => SoundChoices.Select(c => c.Value).MinBy(v => Math.Abs(v - volume)); + private void SetView(Core.Contracts.ViewMode view) { if (Settings.View == view) return; diff --git a/src/ControlFS.Core/Audio/ToneSynth.cs b/src/ControlFS.Core/Audio/ToneSynth.cs index 50d46a4..a30e721 100644 --- a/src/ControlFS.Core/Audio/ToneSynth.cs +++ b/src/ControlFS.Core/Audio/ToneSynth.cs @@ -1,44 +1,67 @@ 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. +/// 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; - /// 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 + /// 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 => [(1320, 28)], - SoundCue.Select => [(880, 55)], - SoundCue.Confirm => [(660, 55), (990, 85)], // sobe - SoundCue.Back => [(660, 55), (440, 85)], // desce - _ => [(880, 40)], + 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 (o pico fica em até ~45% do máximo: discreto mesmo no volume 100). + /// 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 gain = 0.45 * Math.Clamp(volume, 1, 100) / 100.0; - var samples = new List(); - foreach (var (hz, ms) in Notes(cue)) + 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 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 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 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)); + 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 = samples.Count * 2; + var dataBytes = length * 2; writer.Write("RIFF"u8); writer.Write(36 + dataBytes); writer.Write("WAVEfmt "u8); @@ -51,7 +74,7 @@ public static byte[] Render(SoundCue cue, int volume) writer.Write((short)16); writer.Write("data"u8); writer.Write(dataBytes); - foreach (var sample in samples) writer.Write(sample); + 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/Contracts/ISettingsStore.cs b/src/ControlFS.Core/Contracts/ISettingsStore.cs index 5bad706..9f2fb9f 100644 --- a/src/ControlFS.Core/Contracts/ISettingsStore.cs +++ b/src/ControlFS.Core/Contracts/ISettingsStore.cs @@ -96,12 +96,27 @@ public sealed record AppSettings /// public bool GyroKeyboard { get; init; } + /// Volume padrão dos sons do controle (#286): ligados de fábrica, em volume médio. + public const int DefaultControllerSoundVolume = 50; + /// - /// 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. + /// Sons do controle (#276, #286): volume de 0 a 100 escolhido pelo usuário; 0 = desligados. Null = nunca escolheu: vale o + /// padrão (ligado, ). Os sons só tocam nas ações vindas do controle. + /// + public int? ControllerSoundLevel { get; init; } + + /// + /// Só para ler preferências da 0.13 (campo antigo, padrão 0): um valor acima de 0 foi escolha do usuário e vale; 0 foi o + /// padrão de então (indistinguível de "desligei"), então vale o padrão novo. Quem desliga agora grava + /// = 0 e nunca mais é ligado de novo. /// public int ControllerSoundVolume { get; init; } + /// O volume que vale agora (0 = desligados). + [System.Text.Json.Serialization.JsonIgnore] + public int EffectiveControllerSoundVolume => + Math.Clamp(ControllerSoundLevel ?? (ControllerSoundVolume > 0 ? ControllerSoundVolume : DefaultControllerSoundVolume), 0, 100); + /// 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/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs b/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs index bea115d..a3731da 100644 --- a/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs +++ b/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs @@ -13,13 +13,24 @@ private sealed class Recorder : ISoundPlayer } [Fact] - public void Volume_zero_is_silent_and_the_default_setting_is_off() + public void Volume_zero_is_silent() { 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 Sounds_are_on_by_default_keep_a_saved_choice_and_read_the_old_0_13_field() + { + var fresh = new ControlFS.Core.Contracts.AppSettings(); + Assert.Equal(ControlFS.Core.Contracts.AppSettings.DefaultControllerSoundVolume, fresh.EffectiveControllerSoundVolume); // #286: ligados de fábrica + Assert.Equal(0, (fresh with { ControllerSoundLevel = 0 }).EffectiveControllerSoundVolume); // desligados escolhidos: seguem desligados + Assert.Equal(25, (fresh with { ControllerSoundLevel = 25 }).EffectiveControllerSoundVolume); + Assert.Equal(75, (fresh with { ControllerSoundVolume = 75 }).EffectiveControllerSoundVolume); // escolha da 0.13 vale + Assert.Equal(50, (fresh with { ControllerSoundVolume = 0 }).EffectiveControllerSoundVolume); // 0 da 0.13 era o padrão de então + Assert.Equal(0, (fresh with { ControllerSoundVolume = 75, ControllerSoundLevel = 0 }).EffectiveControllerSoundVolume); // a escolha nova vence } [Fact] @@ -77,10 +88,36 @@ public void Synthesized_cues_are_short_valid_wav_files_quieter_at_lower_volume_a 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.InRange(Peak(ToneSynth.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() + { + var levels = Enum.GetValues().ToDictionary(c => c, c => Rms(ToneSynth.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 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 + } + + private static double Rms(byte[] wav) + { + double sum = 0; + var n = 0; + for (var i = 44; i + 1 < wav.Length; i += 2, n++) { var s = BitConverter.ToInt16(wav, i) / 32768.0; sum += s * s; } + return Math.Sqrt(sum / n); + } + private static int Peak(byte[] wav) { var peak = 0;