저는 매일 녹음을 합니다. 처음에는 핸드폰으로 녹음을 했고, 파일 옮기는게 귀찮고 강의를 위해 구입한 마이크로 녹음하고자 지금은 PC에서 녹음을 하고 있습니다. 윈도우에서 제공하는 기본 녹음 앱은 간단하고 원하는 목적에 부합이 되어서 잘 쓰다가 Windows 11 Insider Preview여서 그런지 얼마전부터 오작동을 하네요.
스토어에서 녹음 앱을 받아 쓰고 있는데 만족할 만한 앱이 없습니다. 흠.
그래서 직접 만들어 보려고 합니다.
NAudio에서 지원하고 있으며, ASIO를 지원하는 사운드 드라이버가 필요합니다. (대부분 되는 것으로 알고 있습니다.) 낮은 레이턴시와 높은 해상도를 지원하는 장점이 있는데, 사실 녹음기 만드는데 큰 이점은 없어 보입니다. 경험이 없어 사용해 보는 것입니다.
NAudio에서 ASIO를 사용하는 방법은 너무나 간단합니다.
먼저 AsioOut.GetDriverNames()를 통해 ASIO 목록을 받습니다. 이 목록에 선택해서, AsioOut 개체를 생성합니다.
ar drivers = AsioOut.GetDriverNames();
foreach (var driver in drivers)
{
Console.WriteLine(driver);
}
var driverName = "Focusrite USB ASIO";
var sampleRate = 96000;
using var asioOut = new AsioOut(driverName);
asioOut.InputChannelOffset = 0;
asioOut.InitRecordAndPlayback(null, 1, sampleRate);
여러 채널을 지원하는 장비를 위해 InputChannelOffset 및 InitRecordAndPlayback()함수를 통해 총 채널 중 몇번째 부터 몇개의 채널로 녹음할지를 결정합니다. 저는 단일 채널의 마이크이므로 InputChannelOffset을 0으로, 그리고 InitRecordAndPlayback(null, 1, sampleRate)로 해서 하나의 채널로 설정 합니다.
그 다음 마이크로 입력된 정보를 처리하기 위해 AudioAvalidable에 이벤트를 걸어 줍니다.
var buffer = new float[512];
using var writer = new WaveFileWriter(@"W:\output.wav", new WaveFormat(sampleRate, 1));
다음처럼 생성 후 사용할 수 있습니다.
그런 후, asioOut.Player()를 하면 콘솔에서 해당 장치로 녹음을 할 수 있습니다.
using NAudio.Wave;
Thread.CurrentThread.SetApartmentState(ApartmentState.Unknown);
Thread.CurrentThread.SetApartmentState(ApartmentState.STA);
var drivers = AsioOut.GetDriverNames();
foreach (var driver in drivers)
{
Console.WriteLine(driver);
}
var driverName = "Focusrite USB ASIO";
var sampleRate = 96000;
using var asioOut = new AsioOut(driverName);
asioOut.InputChannelOffset = 0;
asioOut.InitRecordAndPlayback(null, 1, sampleRate);
var buffer = new float[512];
using var writer = new WaveFileWriter(@"W:\output.wav", new WaveFormat(sampleRate, 1));
asioOut.AudioAvailable += (s, e) =>
{
var count = e.GetAsInterleavedSamples(buffer);
writer.WriteSamples(buffer, 0, count);
};
asioOut.Play();
Console.ReadLine();
그런데 NAudio는 결국엔 윈도우에서 제공하는 ASIO 관련 COM 개체를 사용하는 것인데, 최상위문에서는 [STAThread] 속성을 사용할 수가 없으므로
WinUI3 에서 창 제어는 AppWindow를 통해 가능합니다. WPF 개발자 입장에서는 이해하기 힘든 구조긴 합니다만, UWP가 격리된 환경에서 구동되었기 때문에 이를 win32 환경으로 가져오기 위한 중간 계층이 필요한 것으로 보입니다.
AppWindow를 이용하기 위해서는 Window App Sdk Preview1 이상이어야 합니다.
위의 설명으로는 win32에 친숙한 개발자의 경우 AppWindow를 HWND의 상위 추상화로 이해하면 좋다고 합니다.
...
public sealed partial class MainWindow : Window
{
// For the simplicity of this code snippet we import the DLL and declare
// the methods in the MainWindow class here. It is recommended that you
// break this out into a support class that you use wherever needed instead.
// See the Windows App SDK windowing sample for more details.
[DllImport("Microsoft.Internal.FrameworkUdk.dll", EntryPoint = "Windowing_GetWindowHandleFromWindowId", CharSet = CharSet.Unicode)]
private static extern IntPtr GetWindowHandleFromWindowId(WindowId windowId, out IntPtr result);
[DllImport("Microsoft.Internal.FrameworkUdk.dll", EntryPoint = "Windowing_GetWindowIdFromWindowHandle", CharSet = CharSet.Unicode)]
private static extern IntPtr GetWindowIdFromWindowHandle(IntPtr hwnd, out WindowId result);
private AppWindow m_appWindow;
public MainWindow()
{
this.InitializeComponent();
// Get the AppWindow for our XAML Window
m_appWindow = GetAppWindowForCurrentWindow();
if (m_appWindow != null)
{
// You now have an AppWindow object and can call its methods to manipulate the window.
// Just to do something here, let's change the title of the window...
m_appWindow.Title = "WinUI ❤️ AppWindow";
}
}
private AppWindow GetAppWindowForCurrentWindow()
{
IntPtr hWnd = WinRT.Interop.WindowNative.GetWindowHandle(window);
GetWindowIdFromWindowHandle(hWnd, out WindowId myWndId);
return AppWindow.GetFromWindowId(myWndId);
}
}
}
위의 코드를 보면 hWnd로 WindowId를 획득 한 후 AppWindow 개체를 획득합니다. 이를 위해, Microsoft.Internal.FrameworkUdk.dll의 GetWindowHandleFromWindowId() 메소드 및 GetWindowIdFromWindowHandle()메소드를 이용합니다.
(Windows App SDK 실험 버젼에서는 Microsoft.UI.Windowing.Core.dll에서 DllImport 하는데 그대로 실행하면 동작하지 않으니 변경된 DLL을 잘 확인해야 합니다.)
public class CustomWindow : Window
{
private WindowLocationKind _windowLocation;
public int Width
{
get => AppWindow.Size.Width;
set => AppWindow.Resize(new(value, Height));
}
public int Height
{
get => AppWindow.Size.Height;
set => AppWindow.Resize(new(Width, value));
}
public AppWindowPresenterKind Presenter
{
get => AppWindow.Presenter.Kind;
set => AppWindow.TrySetPresenter(value);
}
public WindowLocationKind WindowLocation
{
get => _windowLocation;
set
{
if (value == _windowLocation)
return;
_windowLocation = value;
switch (value)
{
case WindowLocationKind.Default:
break;
case WindowLocationKind.PrimaryCenter:
var displayArea = DisplayArea.Primary;
var x = (displayArea.WorkArea.Width - Width) / 2;
var y = (displayArea.WorkArea.Height - Height) / 2;
AppWindow.MoveAndResize(new(x, y, Width, Height), displayArea);
break;
}
}
}
public AppWindow AppWindow
{
get
{
var windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(this);
return GetAppWindowFromWindowHandle(windowHandle);
}
}
public CustomWindow()
{
}
private static AppWindow GetAppWindowFromWindowHandle(IntPtr windowHandle)
{
Interop.GetWindowIdFromWindowHandle(windowHandle, out var windowId);
return AppWindow.GetFromWindowId(windowId);
}
internal static class Interop
{
[DllImport("Microsoft.Internal.FrameworkUdk.dll", EntryPoint = "Windowing_GetWindowHandleFromWindowId", CharSet = CharSet.Unicode)]
public static extern IntPtr GetWindowHandleFromWindowId(WindowId windowId, out IntPtr result);
[DllImport("Microsoft.Internal.FrameworkUdk.dll", EntryPoint = "Windowing_GetWindowIdFromWindowHandle", CharSet = CharSet.Unicode)]
public static extern IntPtr GetWindowIdFromWindowHandle(IntPtr hwnd, out WindowId result);
}
}
public enum WindowLocationKind
{
Default,
PrimaryCenter
}
아쉽게도 Window는 DependencyObject가 아니므로 DependencyProperty를 만들 수가 없습니다.
Windows App SDK가 미리보기 2가 되면서 CustomWindow.cs를 다음처럼 변경해야 합니다.
public class CustomWindow : Window
{
private WindowLocationKind _windowLocation;
public int Width
{
get => AppWindow.Size.Width;
set => AppWindow.Resize(new(value, Height));
}
public int Height
{
get => AppWindow.Size.Height;
set => AppWindow.Resize(new(Width, value));
}
public AppWindowPresenterKind Presenter
{
get => AppWindow.Presenter.Kind;
set => AppWindow.SetPresenter(value);
}
public WindowLocationKind WindowLocation
{
get => _windowLocation;
set
{
if (value == _windowLocation)
return;
_windowLocation = value;
switch (value)
{
case WindowLocationKind.Default:
break;
case WindowLocationKind.PrimaryCenter:
var displayArea = DisplayArea.Primary;
var x = (displayArea.WorkArea.Width - Width) / 2;
var y = (displayArea.WorkArea.Height - Height) / 2;
AppWindow.MoveAndResize(new(x, y, Width, Height), displayArea);
break;
}
}
}
public AppWindow AppWindow => GetAppWindowForCurrentWidow();
public CustomWindow()
{
//Activated += (s, e) =>
//{
// var a = this.Content;
//};
}
private AppWindow GetAppWindowForCurrentWidow()
{
var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
var winId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hWnd);
return AppWindow.GetFromWindowId(winId);
}
}
그나저나 AppWindow.Create() 로 AppWindow를 가져오지 못하는 것은 의아하네요.
public enum RecordState
{
/// <summary>
/// 녹음 중지 상태
/// </summary>
Stop,
/// <summary>
/// 녹음 중
/// </summary>
Record,
/// <summary>
/// 녹음 일시 정지
/// </summary>
RecordPause,
/// <summary>
/// 재생 중
/// </summary>
Play
}
MVVM를 사용할 것이므로 Microsoft.Toolkit.Mvvm을 패키지를 NuGet을 통해 설치합니다.
바로 IoC를 사용하지는 않고, View-first 패턴으로 View에 ViewModel 속성을 부여하고 View 생성 시 ViewModel도 같이 생성을 해준 후 어느정도 전개가 되었을 떄 IoC를 구현할 예정입니다.
| DMRecorder/RecordPanel.xaml.cs
using DMRecorder.Core.ViewModels;
using Microsoft.UI.Xaml.Controls;
namespace DMRecorder;
public sealed partial class RecordPanel : UserControl
{
public RecordViewModel ViewModel { get; }
public RecordPanel()
{
ViewModel = new();
DataContext = this;
this.InitializeComponent();
}
}
ViewModel은 별도의 프로젝트로 만들고, View를 프로젝트 참조하지 않습니다. 반대로 View는 ViewModel 프로젝트를 참조하도록 설정합니다. 이렇게 하면 ViewModel에서 View에 관련된 코딩을 할 수 없어서 자연스럽게 모듈화가 이루어집니다.
ObservableProperty은 이제 소스 생성기 지원으로 다음처럼 쉽게 구현이 됩니다. 그러나 소스 생성기를 통한 ICommandAttribute 방식은 아직은 canExecute를 지원하지 않으므로 기존 방식대로 코딩합니다.
그리고 또 나중에 설정 변경으로 인해 RecordViewModel의 기본 설정이 변경되어야 하므로 메시지를 수신받을 수 있는 ObservableRecipient으로 시작합니다.
| DMRecorder.Core/ViewModels/RecordViewModel.cs
using Microsoft.Toolkit.Mvvm.ComponentModel;
using Microsoft.Toolkit.Mvvm.Input;
namespace DMRecorder.Core.ViewModels;
public partial class RecordViewModel : ObservableRecipient
{
[ObservableProperty]
public RecordState _recordState;
public RelayCommand<RecordState> RecordCommand { get; }
public RecordViewModel()
{
RecordCommand = new(state =>
{
switch (state)
{
case RecordState.Play:
break;
case RecordState.Stop:
break;
case RecordState.Record:
break;
case RecordState.RecordPause:
break;
}
_recordState = state;
RecordCommand!.NotifyCanExecuteChanged();
}, state => state switch
{
RecordState.Play => _recordState is RecordState.Stop,
RecordState.Stop => _recordState is RecordState.Play or RecordState.Record or RecordState.RecordPause,
RecordState.Record => _recordState is RecordState.Stop,
RecordState.RecordPause => _recordState is RecordState.Record or RecordState.RecordPause,
_ => false
});
}
}