diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 19550daae..3f836288c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,12 +6,17 @@ on: - dev - rafael/* paths-ignore: - - '**/*.md' - - '.github/ISSUE_TEMPLATE/*' - - '.github/workflows/sponsors.yml' - - '.github/workflows/translators.yml' - - 'Graphics/*' - + - "**/*.md" + - ".github/ISSUE_TEMPLATE/*" + - ".github/workflows/sponsors.yml" + - ".github/workflows/translators.yml" + - "Graphics/*" + pull_request: + branches: + - dev + paths-ignore: + - "**/*.md" + - crowdin.yml env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml index 228c14442..55b5a4f80 100644 --- a/.github/workflows/sponsors.yml +++ b/.github/workflows/sponsors.yml @@ -11,7 +11,7 @@ jobs: uses: actions/checkout@v3 - name: Generate Sponsors - uses: JamesIves/github-sponsors-readme-action@v1.2.2 + uses: JamesIves/github-sponsors-readme-action@v1 with: token: ${{ secrets.SPONSORS_PAT }} file: 'README.md' diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a3067d1..e03cb2f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## x.x.x.x +- Fix crash after a connection through Microsoft Remote Desktop - Added support for StartAllBack multitaskbar notification area (thanks @Simplestas!) - Fixed various process lifecycle trigger bugs (thanks @spacechase0!) - Fixed an issue with EarTrumpet tooltips not updating in some scenarios (thanks @Tester798!) diff --git a/EarTrumpet/App.xaml.cs b/EarTrumpet/App.xaml.cs index ee4a2952b..c5a0f092d 100644 --- a/EarTrumpet/App.xaml.cs +++ b/EarTrumpet/App.xaml.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Input; using System.Windows.Interop; @@ -125,11 +126,15 @@ private void ContinueStartup() Exit += (_, __) => SystemEvents.SessionSwitch -= SystemEvents_SessionSwitch; } - private void SystemEvents_SessionSwitch(object sender, SessionSwitchEventArgs e) + private async void SystemEvents_SessionSwitch(object sender, SessionSwitchEventArgs e) { Trace.WriteLine($"Detected User Session Switch: {e.Reason}"); if (e.Reason == SessionSwitchReason.ConsoleConnect) { + // Give the audio subsystem time to settle after the session switch. + // Devices may not be fully available immediately after returning from RDP. + await Task.Delay(2000); + var devManager = WindowsAudioFactory.Create(AudioDeviceKind.Playback); devManager.RefreshAllDevices(); } diff --git a/EarTrumpet/DataModel/Audio/Mocks/AudioDevice.cs b/EarTrumpet/DataModel/Audio/Mocks/AudioDevice.cs index 18b218e99..39adfd843 100644 --- a/EarTrumpet/DataModel/Audio/Mocks/AudioDevice.cs +++ b/EarTrumpet/DataModel/Audio/Mocks/AudioDevice.cs @@ -74,5 +74,10 @@ public void MoveHiddenAppsToDevice(string appId, string id) public void UnhideSessionsForProcessId(uint processId) { } + + public float GetVolumeScalar() => _volume; + public float GetVolumeLogarithmic() => _volume; + public void SetVolumeScalar(float value) => Volume = value; + public void SetVolumeLogarithmic(float value) => Volume = value; } #endif \ No newline at end of file diff --git a/EarTrumpet/DataModel/Audio/Mocks/AudioDeviceSession.cs b/EarTrumpet/DataModel/Audio/Mocks/AudioDeviceSession.cs index 8b32c54a2..e1f368861 100644 --- a/EarTrumpet/DataModel/Audio/Mocks/AudioDeviceSession.cs +++ b/EarTrumpet/DataModel/Audio/Mocks/AudioDeviceSession.cs @@ -105,5 +105,10 @@ public void UpdatePeakValueBackground() { } + + public float GetVolumeScalar() => _volume; + public float GetVolumeLogarithmic() => _volume; + public void SetVolumeScalar(float value) => Volume = value; + public void SetVolumeLogarithmic(float value) => Volume = value; } #endif diff --git a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDevice.cs b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDevice.cs index ec57059f7..22f513576 100644 --- a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDevice.cs +++ b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDevice.cs @@ -109,20 +109,27 @@ public AudioDevice(IAudioDeviceManager deviceManager, IMMDevice device, Dispatch public unsafe void OnNotify(AUDIO_VOLUME_NOTIFICATION_DATA* pNotify) { - _volume = (*pNotify).fMasterVolume; - if (App.Settings.UseLogarithmicVolume) + try { - _deviceVolume.GetMasterVolumeLevel(out _volume); - } - _isMuted = (*pNotify).bMuted != 0; + _volume = (*pNotify).fMasterVolume; + if (App.Settings.UseLogarithmicVolume) + { + _deviceVolume.GetMasterVolumeLevel(out _volume); + } + _isMuted = (*pNotify).bMuted != 0; - _channels.OnNotify((nint)pNotify, *pNotify); + _channels.OnNotify((nint)pNotify, *pNotify); - _dispatcher.Invoke((Action)(() => + _dispatcher.Invoke((Action)(() => + { + RaisePropertyChanged(nameof(Volume)); + RaisePropertyChanged(nameof(IsMuted)); + })); + } + catch (Exception ex) { - RaisePropertyChanged(nameof(Volume)); - RaisePropertyChanged(nameof(IsMuted)); - })); + Trace.WriteLine($"AudioDevice OnNotify: {ex}"); + } } /// diff --git a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceManager.cs b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceManager.cs index e3550ea4a..d19c87df6 100644 --- a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceManager.cs +++ b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceManager.cs @@ -338,12 +338,23 @@ private void TraceLine(string message) public void RefreshAllDevices() { + TraceLine("RefreshAllDevices"); + foreach (var dev in Devices.ToArray()) { ((IMMNotificationClient)this).OnDeviceRemoved(dev.Id); } _default = null; + try + { + _enumerator.UnregisterEndpointNotificationCallback(this); + } + catch (Exception ex) + { + TraceLine($"UnregisterEndpointNotificationCallback during refresh: {ex}"); + } + _enumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator(); _enumerator.RegisterEndpointNotificationCallback(this); @@ -355,13 +366,12 @@ public void RefreshAllDevices() device.GetId(out var deviceId); ((IMMNotificationClient)this).OnDeviceAdded(deviceId); } - this.OnDefaultDeviceChanged(EDataFlow.eRender, ERole.eMultimedia, null); - this.OnDefaultDeviceChanged(EDataFlow.eRender, ERole.eConsole, null); + ((IMMNotificationClient)this).OnDefaultDeviceChanged(Flow, ERole.eMultimedia, default); + ((IMMNotificationClient)this).OnDefaultDeviceChanged(Flow, ERole.eConsole, default); _dispatcher.Invoke((Action)(() => { QueryDefaultDevice(); })); - } } diff --git a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSession.cs b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSession.cs index 7cb30623a..ed9d84e7e 100644 --- a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSession.cs +++ b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSession.cs @@ -426,80 +426,146 @@ private void DisconnectSession() _isDisconnected = true; _dispatcher.BeginInvoke((Action)(() => { - RaisePropertyChanged(nameof(State)); + try + { + RaisePropertyChanged(nameof(State)); + } + catch (Exception ex) + { + Trace.WriteLine($"AudioDeviceSession DisconnectSession dispatch: {ex}"); + } })); } unsafe void IAudioSessionEvents.OnSimpleVolumeChanged(float NewVolume, BOOL NewMute, Guid* EventContext) { - _volume = NewVolume; - _isMuted = NewMute != 0; + try + { + _volume = NewVolume; + _isMuted = NewMute != 0; - _dispatcher.BeginInvoke((Action)(() => + _dispatcher.BeginInvoke((Action)(() => + { + RaisePropertyChanged(nameof(Volume)); + RaisePropertyChanged(nameof(IsMuted)); + })); + } + catch (Exception ex) { - RaisePropertyChanged(nameof(Volume)); - RaisePropertyChanged(nameof(IsMuted)); - })); + Trace.WriteLine($"AudioDeviceSession OnSimpleVolumeChanged: {ex}"); + } } unsafe void IAudioSessionEvents.OnGroupingParamChanged(Guid* NewGroupingParam, Guid* EventContext) { - GroupingParam = *NewGroupingParam; - Trace.WriteLine($"AudioDeviceSession OnGroupingParamChanged {ExeName} {Id}"); - _dispatcher.BeginInvoke((Action)(() => + try { - RaisePropertyChanged(nameof(GroupingParam)); - })); + GroupingParam = *NewGroupingParam; + Trace.WriteLine($"AudioDeviceSession OnGroupingParamChanged {ExeName} {Id}"); + _dispatcher.BeginInvoke((Action)(() => + { + RaisePropertyChanged(nameof(GroupingParam)); + })); + } + catch (Exception ex) + { + Trace.WriteLine($"AudioDeviceSession OnGroupingParamChanged: {ex}"); + } } void IAudioSessionEvents.OnStateChanged(AudioSessionState NewState) { - Trace.WriteLine($"AudioDeviceSession OnStateChanged {NewState} {ExeName} {Id}"); + try + { + Trace.WriteLine($"AudioDeviceSession OnStateChanged {NewState} {ExeName} {Id}"); - _state = NewState; + _state = NewState; - if (_isMoved && NewState == AudioSessionState.AudioSessionStateActive) - { - _isMoved = false; + if (_isMoved && NewState == AudioSessionState.AudioSessionStateActive) + { + _isMoved = false; + } + else if (_moveOnInactive && NewState == AudioSessionState.AudioSessionStateInactive) + { + _isMoved = true; + _moveOnInactive = false; + } + + _dispatcher.BeginInvoke((Action)(() => + { + try + { + RaisePropertyChanged(nameof(State)); + } + catch (Exception ex) + { + Trace.WriteLine($"AudioDeviceSession OnStateChanged dispatch: {ex}"); + } + })); } - else if (_moveOnInactive && NewState == AudioSessionState.AudioSessionStateInactive) + catch (Exception ex) { - _isMoved = true; - _moveOnInactive = false; + Trace.WriteLine($"AudioDeviceSession OnStateChanged: {ex}"); } - - _dispatcher.BeginInvoke((Action)(() => - { - RaisePropertyChanged(nameof(State)); - })); } unsafe void IAudioSessionEvents.OnDisplayNameChanged(PCWSTR NewDisplayName, Guid* EventContext) { - ChooseDisplayName(NewDisplayName.ToString()); + try + { + ChooseDisplayName(NewDisplayName.ToString()); - _dispatcher.BeginInvoke((Action)(() => + _dispatcher.BeginInvoke((Action)(() => + { + RaisePropertyChanged(nameof(DisplayName)); + })); + } + catch (Exception ex) { - RaisePropertyChanged(nameof(DisplayName)); - })); + Trace.WriteLine($"AudioDeviceSession OnDisplayNameChanged: {ex}"); + } } - void IAudioSessionEvents.OnSessionDisconnected(AudioSessionDisconnectReason DisconnectReason) => DisconnectSession(); + void IAudioSessionEvents.OnSessionDisconnected(AudioSessionDisconnectReason DisconnectReason) + { + try + { + DisconnectSession(); + } + catch (Exception ex) + { + Trace.WriteLine($"AudioDeviceSession OnSessionDisconnected: {ex}"); + } + } unsafe void IAudioSessionEvents.OnChannelVolumeChanged(uint ChannelCount, float[] afNewChannelVolume, uint ChangedChannel, Guid* EventContext) { - var channelVolumesValues = new float[ChannelCount]; - Array.Copy(afNewChannelVolume, 0, channelVolumesValues, 0, (int)ChannelCount); + try + { + var channelVolumesValues = new float[ChannelCount]; + Array.Copy(afNewChannelVolume, 0, channelVolumesValues, 0, (int)ChannelCount); - for (var i = 0; i < ChannelCount; i++) + for (var i = 0; i < ChannelCount; i++) + { + _channels.Channels[i].SetLevel(channelVolumesValues[i]); + } + } + catch (Exception ex) { - _channels.Channels[i].SetLevel(channelVolumesValues[i]); + Trace.WriteLine($"AudioDeviceSession OnChannelVolumeChanged: {ex}"); } } unsafe void IAudioSessionEvents.OnIconPathChanged(PCWSTR NewIconPath, Guid* EventContext) { - IconPath = NewIconPath.ToString(); - RaisePropertyChanged(nameof(IconPath)); + try + { + IconPath = NewIconPath.ToString(); + RaisePropertyChanged(nameof(IconPath)); + } + catch (Exception ex) + { + Trace.WriteLine($"AudioDeviceSession OnIconPathChanged: {ex}"); + } } } diff --git a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSessionCollection.cs b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSessionCollection.cs index 9fcf41d46..802229e27 100644 --- a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSessionCollection.cs +++ b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSessionCollection.cs @@ -74,14 +74,21 @@ private void CreateAndAddSession(IAudioSessionControl session) var newSession = new AudioDeviceSession(parent, session, _dispatcher); _dispatcher.BeginInvoke((Action)(() => { - if (newSession.State == SessionState.Moved) + try { - _movedSessions.Add(newSession); - newSession.PropertyChanged += MovedSession_PropertyChanged; + if (newSession.State == SessionState.Moved) + { + _movedSessions.Add(newSession); + newSession.PropertyChanged += MovedSession_PropertyChanged; + } + else if (newSession.State != SessionState.Expired) + { + AddSession(newSession); + } } - else if (newSession.State != SessionState.Expired) + catch (Exception ex) { - AddSession(newSession); + Trace.WriteLine($"AudioDeviceSessionCollection CreateAndAddSession dispatch: {ex}"); } })); } diff --git a/LICENSE b/LICENSE index f33bf2ab5..7a134c6c6 100644 --- a/LICENSE +++ b/LICENSE @@ -5,6 +5,7 @@ software or associated documentation files: Yellow Elephant Productions Tidal Media Inc. + Articent Group LLC The Excluded Entities may not exercise any of the rights granted to other users under these licensing terms. diff --git a/README.md b/README.md index 1340818ee..3c219cee2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # EarTrumpet -[![GitHub issues](https://img.shields.io/github/issues/File-New-Project/EarTrumpet?style=flat-square)](https://github.com/File-New-Project/EarTrumpet/issues) [![GitHub forks](https://img.shields.io/github/forks/File-New-Project/EarTrumpet?style=flat-square)](https://github.com/File-New-Project/EarTrumpet/network) [![GitHub stars](https://img.shields.io/github/stars/File-New-Project/EarTrumpet?style=flat-square)](https://github.com/File-New-Project/EarTrumpet/stargazers) [![GitHub license](https://img.shields.io/github/license/File-New-Project/EarTrumpet?style=flat-square)](https://github.com/File-New-Project/EarTrumpet/blob/master/LICENSE) [![Nuget package](https://img.shields.io/chocolatey/v/eartrumpet?style=flat-square)](https://chocolatey.org/packages/eartrumpet) ![Maintenance status](https://img.shields.io/maintenance/yes/2024?style=flat-square) +[![GitHub issues](https://img.shields.io/github/issues/File-New-Project/EarTrumpet?style=flat-square)](https://github.com/File-New-Project/EarTrumpet/issues) [![GitHub forks](https://img.shields.io/github/forks/File-New-Project/EarTrumpet?style=flat-square)](https://github.com/File-New-Project/EarTrumpet/network) [![GitHub stars](https://img.shields.io/github/stars/File-New-Project/EarTrumpet?style=flat-square)](https://github.com/File-New-Project/EarTrumpet/stargazers) [![Nuget package](https://img.shields.io/chocolatey/v/eartrumpet?style=flat-square)](https://chocolatey.org/packages/eartrumpet) ![Maintenance status](https://img.shields.io/maintenance/yes/2025?style=flat-square) ![EarTrumpet Screenshot](./Graphics/hero.gif) @@ -12,6 +12,11 @@ ## Media coverage +> [...] there are alternative solutions like EarTrumpet which are great [...] +> +> — [Linus Tech Tips (February 12, 2025)](https://youtu.be/6wgHq9NZru0?t=461) + + > [...] there are third-party solutions out there that do a much better job than what Windows offers by default. One such app is called EarTrumpet [...] > > — [TechQuickie (Jan 11, 2022)](https://www.youtube.com/watch?v=xQvp5HzY9xc) @@ -26,7 +31,7 @@ ## Sponsors -Bastien Auda + ## Features @@ -47,7 +52,7 @@ Thanks to our translators, we currently support 20+ languages. Are we missing yo ### Translators -BlockyTheDev casungo cmhdream cronhan CrysoK Dekamir Denis21010 dkopec91 Drasil ewerkman fashni FoxyLoon gbetsis GoldenTao heerxingen ivoo_ JordyEGNL jsunyermias JY3 kutiz Le_Roi_Fromage luisvalles luukverhagen96 merah26 noseran Orofil OsoFugitivo poipoi random.001 Randomname456 siprach Tihsamikah weyh Yannick_Whorst yinyue200 zxcx chreddy gable napa8135 RazvanS Taifuuni 140bpmdubstep alpdmrel Apolônio Serafim Arcade Ariel Eytan Artan Imeri Artem Artem Bazhenov Bazsa Berk Kırıkçı Carl Oosthuizen Corey Daan Schroeten Davin Risy A. H. Dimitris Traxiotis Donato duk6046 Dy64 Edison Lee Eduardo Junio Elias Torstensen Emre EN LYOWH Enes 3078 Epic gaming chair ExteriorCloth20 Firefly74940 Fred Ahrens Gabriel Lima Gabriele Göran Guillaume Turchini hirin-byte IDAぷろ iGerman00 iMiKE (Borizzz from XDA) Izumi W. Jabir Abdullah Haian Jacek Majda Jaiganésh Kumaran Jaime Muñoz Martín Jan Formela Jerem Dlcn Jeroen Maathuis Jhongt796 Jiaa johnyb0y Jose Calderon Jules Kolala LastRagnarokkr Leon San José Larsson likegravity Lilian ARAGO maboroshin Marco P. Marco te Kortschot Marian Dolinský Martijn van B. Mat GMC7 Melcon Moraes Merloss Miguel Hurtado Mohammad Shughri muhammad sadq Nikola Perović Nil Calderó Vega Nissan-O notlin4 NTAuthority Omar Mostafa Pasvv19 Pavel Bibichenko Peter Pin-guang Chen Pol Quartyn Walker Rafael Rivera Raul Andres del Canto Zahr Rodrigo Rodrigo Garcia Martin Roman Matviiv Sebastian Soldat Shahdan Turung somni Sophia Sarah Spaanhede Stanislav Steve Y Sus Juegos Syuugo Tci Gravifer Fang Ten tu není Thatchai Sintra Thiago Henrique da Silva Thiago Ramos u!^DEV Umang Chauhan WCONTI Wessel Smid Yacine Boussoufa Yaniv Levin yrctw YuDong Λόρδος Πορτοκάλης Вова Смірнов Михаил Эпштейн 曹恩逢 王柏崴 +4tubborn BlockyTheDev casungo cmhdream cronhan CrysoK Dekamir dkopec91 Drasil drodrigues55 evasive ewerkman fashni FoxyLoon gbetsis GoldenTao heerxingen hypnotichemionus4 ivoo_ jamilfarajov JordyEGNL jsunyermias JY3 kutiz Le_Roi_Fromage luisvalles luukverhagen96 merah26 NazeehYauqoob noseran Orofil OsoFugitivo poipoi random.001 Randomname456 segewick siprach solomoncyj Tihsamikah TrDy73 weyh Yannick_Whorst yinyue200 Zababa chreddy gable RazvanS Taifuuni 140bpmdubstep alpdmrel Andrew Poženel Apolônio Serafim Arcade Ariel Eytan Artan Imeri Artem Artem Bazhenov Bazsa Berk Kırıkçı Cain Carl Oosthuizen Charles IdB Corey Daan Schroeten Davin Risy A. H. Dimitris Traxiotis Donato duk6046 Dy64 Eduardo Junio Elias Torstensen Emre EN LYOWH Enes 3078 Epic gaming chair Ettore Bernardi ExteriorCloth20 Firefly74940 Fred Ahrens Gabriel Lima Gabriele GiHeong Ro Göran Guillaume Turchini Gustavo Abreu HibouDev Hichem Fantar hirin-byte hugoalh IDAぷろ iGerman00 Igor Ivić Iman Nemati iMiKE (Borizzz from XDA) Italo King Muñoz Izumi W. Jacek Majda Jaiganėsh Kumaran Jaime Muñoz Martín Jan Formela Jerem Dlcn Jeroen Maathuis Jhongt796 Jiaa johnyb0y Jose Calderon Jules LastRagnarokkr Leon San José Larsson likegravity Lilian ARAGO maboroshin Marco P. Marco te Kortschot Marian Dolinský Martijn van B. Mat GMC7 Melcon Moraes Merloss Miguel Hurtado Mohammad Shughri Mr Mati muhammad sadq Nicolas Panico Nikola Perović Nil Calderó Vega Nissan-O NTAuthority Omar Mostafa Pasvv19 Pavel Bibichenko Peter J. Mello Peter Pin-guang Chen Pol Quartyn Walker Rafael Rivera Raul Andres del Canto Zahr Really Super Otter Rodrigo Rodrigo Garcia Martin Roman Matviiv Sándor Papp Sebastian Soldat Shahdan Turung Shahin Alam (shahin alam) Shizzoid ShlomoDev somni Sophia Sarah Spaanhede Stanislav Steve Y Sus Juegos Tci Gravifer Fang Ten tu není th ch Thatchai Sintra Thiago Henrique da Silva Tungstene u!^DEV Umang Chauhan wancoro WCONTI Wessel Smid Yacine Boussoufa Yaniv Levin Yasoga Nanayakkarawasam YifePlayte yrctw YuDong yuna0x0 Λόρδος Πορτοκάλης Вова Смірнов Кирилл Карасёв Михаил Эпштейн י. פל. 曹恩逢 王柏崴 神枪968 ## Install @@ -71,21 +76,100 @@ Want to see what we were working on? Or help us test new features? [Install EarT * [Change Log](./CHANGELOG.md) ## Supported operating systems +- Windows 10 1803 (April 2018 Update) +- Windows 10 1809 (October 2018 Update) +- Windows 10 1903 (May 2019 Update) +- Windows 10 1909 (November 2019 Update) - Windows 10 2004 (May 2020 Update) - Windows 10 20H2 (October 2020 Update) - Windows 10 21H1 (May 2021 Update) - Windows 10 21H2 (November 2021 Update) +- Windows 10 22H2 (October 2022 Update) - Windows 11 -- Windows 11 22H2 (2022 Update) -- Windows 11 23H2 (2023 Update) -- Windows 11 24H2 (2024 Update) -## Credits (by last name) -- Dave Amenta ([@davux](https://www.twitter.com/davux)) +## Credits - David Golden ([@GoldenTao](https://www.twitter.com/GoldenTao)) - Rafael Rivera ([@WithinRafael](https://www.twitter.com/WithinRafael)) +- Dave Amenta ([@davux](https://www.twitter.com/davux)) - [Contributors](https://github.com/File-New-Project/EarTrumpet/graphs/contributors) ## Special thanks "[Horn](https://thenounproject.com/icon/horn-125731/)" icon by Artjom Korman from [the Noun Project](https://thenounproject.com/) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +