녹음 어플리케이션 만들기 - slog

저는 매일 녹음을 합니다. 처음에는 핸드폰으로 녹음을 했고, 파일 옮기는게 귀찮고 강의를 위해 구입한 마이크로 녹음하고자 지금은 PC에서 녹음을 하고 있습니다. 윈도우에서 제공하는 기본 녹음 앱은 간단하고 원하는 목적에 부합이 되어서 잘 쓰다가 Windows 11 Insider Preview여서 그런지 얼마전부터 오작동을 하네요.

스토어에서 녹음 앱을 받아 쓰고 있는데 만족할 만한 앱이 없습니다. 흠.
그래서 직접 만들어 보려고 합니다.

목표

  • 간단한 녹음 기능
    • 녹음 버튼, 일시중지, 정지 기능
    • 녹음된 파일 목록 표시 및 파일명 바꾸기, 드래그앤드롭
  • 녹음 효과
    • Win2D를 이용해 녹음되고 있는 소리를 실시간 파장으로 표현

목표 달성 후 추가

  • 녹음 시 스피커로 바로 출력 (옵션)
  • 노이즈 필터
  • 저장소 SFTP 및 Google Drive 지원
좋아요 3

녹음의 기본 기능은 사실 라이브러리를 이용하면 거의 대부분 구현할게 없습니다.

마이크를 통해 녹음하기 위해 WaveIn, WASAPI, ASIO API를 이용할 수 있습니다. 관련 샘플도 잘 제공하고 있습니다.

좋아요 1

소리는 파장으로 소리의 높낮이가 결정됩니다. 파장이 짧으면 고음, 길면 저음이 됩니다. 사람이 들을 수 있는 영역은 20 Hz ~ 20,000 Hz 정도라고 합니다.

녹음된 소리를 저/중/고 음역대로 출렁이게 보이게 하려고 했는데, 이거 배보다 배꼽이 더 커집니다. Win2D 적용을 잠시 미루고, 바로 녹음 볼륨만 표시하는 것으로 목표를 변경합니다.

좋아요 2

녹음은 ASIO를 사용하려 합니다.

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);

여러 채널을 지원하는 장비를 위해 InputChannelOffsetInitRecordAndPlayback()함수를 통해 총 채널 중 몇번째 부터 몇개의 채널로 녹음할지를 결정합니다. 저는 단일 채널의 마이크이므로 InputChannelOffset0으로, 그리고 InitRecordAndPlayback(null, 1, sampleRate)로 해서 하나의 채널로 설정 합니다.

그 다음 마이크로 입력된 정보를 처리하기 위해 AudioAvalidable에 이벤트를 걸어 줍니다.

asioOut.AudioAvailable += (s, e) =>
{
    var count = e.GetAsInterleavedSamples(buffer);

    writer.WriteSamples(buffer, 0, count);
};

여기서 writer는 비압축 WAV Writer로,

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] 속성을 사용할 수가 없으므로

다음 처럼,

Thread.CurrentThread.SetApartmentState(ApartmentState.Unknown);
Thread.CurrentThread.SetApartmentState(ApartmentState.STA);

설정 후 사용할 수 있게 됩니다.

NAudio는 다양한 사운드 확장자(wav, mp4, wma, mp3, flac, m4a)를 지원합니다.

좋아요 4

이제 WinUI 3을 이용해 화면을 구성해 봅시다. WinUI 3은 win32용 어플리케이션을 만 들 수 있는데 아직은 지원이 미비합니다. 일례로 윈도 창 사이즈를 Window 클래스의 속성으로 조절할 수 없습니다. WPF 개발자라면 참 황당할 수 있는데요,

Window 클래스 제공하는 속성을 살펴보면, 당황스러울 정도로 적습니다. Window 클래스는 IWindow를 구현한 것이므로 IWindow를 살펴보면,

    internal interface IWindow
    {
        Rect Bounds { get; }
        Compositor Compositor { get; }
        UIElement Content { get; set; }
        CoreWindow CoreWindow { get; }
        CoreDispatcher Dispatcher { get; }
        DispatcherQueue DispatcherQueue { get; }
        bool ExtendsContentIntoTitleBar { get; set; }
        string Title { get; set; }
        bool Visible { get; }

        event TypedEventHandler<object, WindowActivatedEventArgs> Activated;
        event TypedEventHandler<object, WindowEventArgs> Closed;
        event TypedEventHandler<object, WindowSizeChangedEventArgs> SizeChanged;
        event TypedEventHandler<object, WindowVisibilityChangedEventArgs> VisibilityChanged;

        void Activate();
        void Close();
        void SetTitleBar(UIElement titleBar);
    }

아니… 간단해서 좋긴 한데 뭐 제공 하는 것이 타이틀 변경하는 정도네요;
이는 Windows App SDK Windowing이 완전히 구현되는 1.0 부터 개선될 것으로 보입니다.

좋아요 1

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.dllGetWindowHandleFromWindowId() 메소드 및 GetWindowIdFromWindowHandle()메소드를 이용합니다.
(Windows App SDK 실험 버젼에서는 Microsoft.UI.Windowing.Core.dll에서 DllImport 하는데 그대로 실행하면 동작하지 않으니 변경된 DLL을 잘 확인해야 합니다.)

이제 윈도 사이즈를 조정할 수 있게 됩니다. 관련 샘플은 이곳을 참조하세요.

좋아요 1

AppWindow를 이용해 전체화면 등 윈도의 형태를 만들 수 있습니다. Presenter를 이용하는 것인데요, 제공하는 Presenter는 CompactOverlay, FullScreen, Overlapped(기본) 입니다. 각각 다음과 같습니다.

CompactOvelay
image

FullScreen

Overlapped

그리고 툴바의 색도 변경할 수 있고,

사용자 툴바도 적용할 수 있습니다.

Windows App SDK 0.8 기준으로 테마에 따라 상단 툴바도 같이 변하면 좋은데, 아직은 변하지 않아 어색했는데요, 일단 이런 기능을 통해 좀 더 보완이 되겠습니다.

현재는 어색합니다.
image

좋아요 1

이제 AppWindow를 이용해 녹음 앱의 윈도 형태를 만들어 봅시다. 먼저 Windows App Sdk 패키지를 1.0.0- preview1로 변경해야 합니다.

csproj

  <ItemGroup>
      <PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.22000.194" />
      <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.0.0-preview1" />
      <Manifest Include="$(ApplicationManifest)" />
  </ItemGroup>

그리고 패키지 프로젝트도 수정합시다.

...
  <ItemGroup>
    <PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.22000.194" />
    <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.0.0-preview1" />
  </ItemGroup>
...

AppWindow를 좀 더 편하게 쓰기 위해 CustomWindow를 만들었습니다.

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
    }

아쉽게도 WindowDependencyObject가 아니므로 DependencyProperty를 만들 수가 없습니다.

이제 MainWindow.xaml 및 cs를 변경합시다.

MainWindow.xaml

<local:CustomWindow
    x:Class="DMRecorder.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:DMRecorder"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Width="800" Height="300"
    Presenter="CompactOverlay"
    WindowLocation="PrimaryCenter"
    >

    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
        <Button x:Name="myButton" Click="myButton_Click">Click Me</Button>
    </StackPanel>
</local:CustomWindow>

MainWindow.xaml.cs

    public sealed partial class MainWindow : CustomWindow
    {
        public MainWindow()
        {
            this.InitializeComponent();
        }

        private void myButton_Click(object sender, RoutedEventArgs e)
        {
            myButton.Content = "Clicked";
        }
    }

이제 Window의 너비, 높이와 Presenter를 지정할 수 있고, 메인 화면의 정 중앙에 윈도를 배치할 수 있게 되었습니다.

좋아요 1

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를 가져오지 못하는 것은 의아하네요.

좋아요 1

다국어 지원을 추가합니다.

String/ko-KR/Resources.resw 및
String/en-US/Resources.resw 를 다음과 같이 추가합니다.

MainWindow.xaml의 Window에 직접 x:Uid로 추가하고 싶지만 Window.Title이 DependencyProperty가 아니므로 안됩니다.

ResourceLoader를 편하게 사용하기 위해 string 메소드 확장을 만든 후,

public static class ResourceExtension
{
    private static ResourceLoader _resLoader = new();

    public static string GetLocalized(this string resourceKey)
    {
        return _resLoader.GetString(resourceKey);
    }
}

Resources.resw에 언어에 맞게 ApplicationTitle 이름으로 디모녹음기DMRecorder 값을 넣고 MainWindow 생성자에 Title을 삽입합니다.

| MainWindow.cs

...
        public MainWindow()
        {
            this.InitializeComponent();

            Title = "ApplicationTitle".GetLocalized();
        }
...

image

좋아요 1

Window는 값을 바인딩 할 수 없으므로 별도의 UserControl을 만들어 붙였습니다. 표현되는 아이콘은 FontIcon을 이용해서 Segoe Fluent Icons 아이콘 폰트를 이용했습니다.

| RecordPanel.xaml

<UserControl
    x:Class="DMRecorder.RecordPanel"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:DMRecorder"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">

    <Grid>
        <CommandBar IsOpen="False" Background="Transparent" OverflowButtonVisibility="Collapsed" Opacity="0.9">
            <!-- Play -->
            <AppBarToggleButton IsTabStop="False">
                <AppBarToggleButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe768;" />
                </AppBarToggleButton.Icon>
            </AppBarToggleButton>
            <!-- Stop -->
            <AppBarButton IsTabStop="False">
                <AppBarButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe71a;" />
                </AppBarButton.Icon>
            </AppBarButton>

            <AppBarSeparator />

            <!-- Record -->
            <AppBarToggleButton IsTabStop="False">
                <AppBarToggleButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe7c8;" Foreground="OrangeRed" />
                </AppBarToggleButton.Icon>
            </AppBarToggleButton>
            <!-- Pause -->
            <AppBarToggleButton IsTabStop="False">
                <AppBarToggleButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe769;" />
                </AppBarToggleButton.Icon>
            </AppBarToggleButton>

            <AppBarSeparator />

            <AppBarButton x:Uid="Settings" IsTabStop="False">
                <AppBarButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe713;" />
                </AppBarButton.Icon>
            </AppBarButton>
        </CommandBar>
    </Grid>
</UserControl>

| MainWindow.xaml

<local:CustomWindow
    x:Class="DMRecorder.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:DMRecorder"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Presenter="CompactOverlay"
    WindowLocation="PrimaryCenter"
    Width="380" Height="200"
    >

    <Grid>
        <local:RecordPanel />
    </Grid>
</local:CustomWindow>

image

좋아요 1

녹음의 상태는 다음으로 정의합니다.

    public enum RecordState
    {
        /// <summary>
        /// 녹음 중지 상태
        /// </summary>
        Stop,
        /// <summary>
        /// 녹음 중
        /// </summary>
        Record,
        /// <summary>
        /// 녹음 일시 정지
        /// </summary>
        RecordPause,
        /// <summary>
        /// 재생 중
        /// </summary>
        Play
    }
좋아요 2

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
            });
    }
}

이제 RecordPanl.xaml에 Command를 연결합니다.

<UserControl
    x:Class="DMRecorder.RecordPanel"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:DMRecorder"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:core="using:DMRecorder.Core"
    mc:Ignorable="d">

    <Grid>
        <CommandBar IsOpen="False" Background="Transparent" OverflowButtonVisibility="Collapsed" Opacity="0.9">
            <!-- Play -->
            <AppBarToggleButton IsTabStop="False" Command="{x:Bind ViewModel.RecordCommand}">
                <AppBarToggleButton.CommandParameter>
                    <core:RecordState>Play</core:RecordState>
                </AppBarToggleButton.CommandParameter>
                <AppBarToggleButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe768;" />
                </AppBarToggleButton.Icon>
            </AppBarToggleButton>
            <!-- Stop -->
            <AppBarButton IsTabStop="False" Command="{x:Bind ViewModel.RecordCommand}">
                <AppBarButton.CommandParameter>
                    <core:RecordState>Stop</core:RecordState>
                </AppBarButton.CommandParameter>
                <AppBarButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe71a;" />
                </AppBarButton.Icon>
            </AppBarButton>

            <AppBarSeparator />

            <!-- Record -->
            <AppBarToggleButton IsTabStop="False" Command="{x:Bind ViewModel.RecordCommand}">
                <AppBarToggleButton.CommandParameter>
                    <core:RecordState>Record</core:RecordState>
                </AppBarToggleButton.CommandParameter>
                <AppBarToggleButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe7c8;" Foreground="OrangeRed" />
                </AppBarToggleButton.Icon>
            </AppBarToggleButton>
            <!-- Pause -->
            <AppBarToggleButton IsTabStop="False" Command="{x:Bind ViewModel.RecordCommand}">
                <AppBarToggleButton.CommandParameter>
                    <core:RecordState>RecordPause</core:RecordState>
                </AppBarToggleButton.CommandParameter>
                <AppBarToggleButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe769;" />
                </AppBarToggleButton.Icon>
            </AppBarToggleButton>

            <AppBarSeparator />

            <AppBarButton x:Uid="Settings" IsTabStop="False">
                <AppBarButton.Icon>
                    <FontIcon FontFamily="Segoe Fluent Icons" Glyph="&#xe713;" />
                </AppBarButton.Icon>
            </AppBarButton>
        </CommandBar>
        <!--<TextBlock Text="{x:Bind ViewModel.RecordState, Mode=TwoWay}" />-->
    </Grid>
</UserControl>

image

좋아요 2

녹음 파일명에 패턴 인자를 사용하기 위한 방법입니다. 정규식을 이용하면 쉽게 구현이 됩니다.

| 코드

using System.Text.RegularExpressions;

var text = "record({DATE})({DUPNUM})";
Console.WriteLine(text);
Regex regex = new(@"{([^}]+)}");

var result = regex.Replace(text, match => match.Groups[1].Value switch
{
    "DATE" => DateTime.Now.ToShortDateString(),
    "DUPNUM" => "2",
    _ => ""
});

Console.WriteLine(result);

| 결과

record({DATE})({DUPNUM})
record(2021-10-17)(2)

하지만 문자열로 직접 비교하는 것은 수정 시 실수할 수 있는 여지가 있어 enum 버젼으로 바꾸면 다음과 같습니다.

| 수정 코드

using System.Text.RegularExpressions;

var text = $"record({{{FilenamePatternParams.DATE}}})({{{FilenamePatternParams.DUPNUM}}})";
Console.WriteLine(text);

Regex regex = new(@"{([^}]+)}");

var result = regex.Replace(text, match =>
{
    var bResult = Enum.TryParse<FilenamePatternParams>(match.Groups[1].Value, out var patternParam);
    if (bResult == false)
        return match.Value;

    return patternParam switch
    {
        FilenamePatternParams.DATE => DateTime.Now.ToShortDateString(),
        FilenamePatternParams.DUPNUM => "2",
        _ => ""
    };
});

Console.WriteLine(result);

public enum FilenamePatternParams
{
    /// <summary>
    /// 오늘 날짜
    /// </summary>
    DATE,
    /// <summary>
    /// 중복 파일이름 순번
    /// </summary>
    DUPNUM
}