65.9K
CodeProject 正在变化。 阅读更多。
Home

MP3 录音工具

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.72/5 (36投票s)

2010 年 2 月 6 日

CPOL

12分钟阅读

viewsIcon

386456

downloadIcon

17767

一个小型的单对话框实用程序,用于以 MP3 格式从声卡录音

引言

这是一个用于以 MP3 格式从声卡录音的应用程序。此实用工具为控制台应用程序mp3_stream.exe 添加了用户界面,该应用程序由 rtybase 在 CodeProject 上的一篇文章中描述。它还添加了新功能,例如设置录音时长以自动结束长时间的录音会话。其他功能包括在退出时保存当前用户设置,并在下次启动工具时重新加载这些设置。

从技术角度来看,本文描述了如何通过将第三方控制台应用程序作为单独进程启动,并通过命令行参数和回调方法与其通信来控制它,同时利用 async/await 来保持用户界面的流畅和响应。

背景

我很快地将这个实用工具组合起来,以替换我用来驱动mp3_stream.exe 的一些 DOS 批处理文件。然后我很快意识到添加一个持续时间计时器会很有用,这样可以在无人看管的情况下结束录音。我发现这在录制较长时间的会话时特别有用,其中一些会话可能超过六个小时。

使用该实用工具

对话框显示了代表mp3_stream.exe 命令行参数的设置,例如音量、比特率(每秒千比特)例如 128。设备和线路名称显示在下拉列表框中。可以为录音的总时长指定一个可选值,并且可以使用标准的 Windows“另存为”对话框浏览要保存录音的文件名。所有配置设置在关闭实用工具时保存,并在下次启动时自动重新加载。

Windows 7、8、8.1 和 10 可用性说明

自首次发布这篇文章以来,越来越多的人开始在 Windows XP 之后的操作系统中使用它。在较新版本的 Windows 中遇到的问题是,即使立体声混音设备可以通过声卡驱动程序访问,操作系统也会默认禁用并隐藏它。克服这个问题相当直接,如下所示:

  1. 右键单击系统托盘中的扬声器图标,然后选择录制设备(您也可以通过 Windows 控制面板打开此对话框)
  2. 在对话框内右键单击以打开上下文菜单,然后选择显示禁用的设备
  3. 再次在对话框内右键单击以打开上下文菜单,这次选择显示已断开连接的设备
  4. 以上两步将使立体声混音可见,右键单击它并选择启用
  5. 确定按钮关闭对话框

现在,下次启动该实用工具时,您将能够选择立体声混音设备和主音量线路。

另外值得一提的是,声卡驱动程序,如制造商(例如 Realtek)提供的,在新购买的机器上可能并非总是默认安装。在许多情况下,只安装了标准的通用 Windows 驱动程序。这很容易通过下载制造商的驱动程序并将其安装在机器上来解决。然后就可以执行上述步骤 1 到 5,并享受从声卡录音的好处。

代码工作原理

mp3_stream.exe 本身是一个 C++ 应用程序,它使用 LAME 开源库来执行 MP3 编码。要使用 .NET 应用程序与 LAME 通信,有几种方法可以实现。例如,可以在mp3_stream.exe 源代码中添加一个托管 C++ 的薄接口层并重新编译它,以便可以通过 .NET 访问它。或者,可以在 C# 项目端(使用[DllImport])添加一个薄接口层,直接访问 LAME DLL 功能。

然而,mp3_stream.exe 实际上已经提供了一个可以从 .NET 完美访问的 API。这也许不是人们通常意义上所理解的 API,因为它仅仅是mp3_stream.exe 支持的命令行参数。尽管如此,它仍然是一个可编程接口,可以完美地访问所需的功能。例如,要枚举系统支持的声卡,mp3_stream.exe 会使用-device 参数调用。要以编程方式执行此操作,会使用 `System.Diagnostics.Process` 来启动一个mp3_stream.exe 进程,并为其提供适当的参数。这是由 `Execute` 方法完成的,该方法又调用 `InitiateMP3StreamProcess` 方法。

private static Process InitiateMp3StreamProcess(string arguments, EventHandler onExecutionCompleted)
{
    var recordingProc = new Process
    {
        StartInfo =
        {
            CreateNoWindow = true,
            WorkingDirectory = Application.StartupPath,
            FileName = "mp3_stream.exe",
            Arguments = arguments,
            UseShellExecute = false,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            RedirectStandardInput = true,
        },
        EnableRaisingEvents = true
    };

    if (onExecutionCompleted != null)
        recordingProc.Exited += onExecutionCompleted;

    recordingProc.Start();
    return recordingProc;
}

private static IEnumerable<string> Execute(string command)
{
    var process = InitiateMp3StreamProcess(command, null);
    process.WaitForExit();

    return ReadResponseLines(process);
}</string>

`onExecutionCompleted` 参数可用于提供一个回调事件,该事件在外部进程完成并退出时被调用。此回调对于较长的操作很有用,例如执行录音时,并且可以在下面的录音部分看到。

然而,其他操作几乎是瞬时的,不会在可观的时间内阻止用户界面。对于这类操作,无需提供回调,我们可以简单地使用 `Process.WaitForExit()` 来等待进程完成所需的几毫秒。这种交互类型可以被认为是直接执行,可以通过 `Execute` 方法调用。

`Execute` 方法调用时带有一个 `string` 参数,该参数指定了需要传递给mp3_stream.exe 进程的命令行参数。

Execute("-devices");

这与使用 ` -device ` 参数结合使用,用于将 `DeviceLines` 字典迭代地填充所有可用的设备和线路。

private void PopulateDevices()
{
    var devices = Execute("-devices");
    foreach (var device in devices)
    {
        Devices.Items.Add(device);

        DeviceLines.Add(device, new List<string>());
        var lines = Execute($"-device=\"{device}\"");

        foreach (var line in lines)
        {
            DeviceLines[device].Add(line);
        }
    }
}</string>

Recording

一旦配置完成并按下录制按钮,该实用工具就会显示录音完成的剩余时间。这是使用计时器完成的,计时器会更新进度条和剩余时间的文本显示。

private void UpdateTimeRemaining(TimeSpan timeSpan)
{
    TimeRemaining.Text =
        @"Time remaining: " +
        $"{timeSpan.Hours.ToString().PadLeft(2, '0')}:"+
        $"{timeSpan.Minutes.ToString().PadLeft(2, '0')}:"+
        $"{timeSpan.Seconds.ToString().PadLeft(2, '0')}";
}

private void Timer_Tick(object sender, EventArgs e)
{
    var timeNow = DateTime.Now;

    if (timeNow >= EndTime)
    {
        OnStopRecording();
    }
    else
    {
        UpdateProgressIndicators(timeNow);
    }
}

private void UpdateProgressIndicators(DateTime timeNow)
{
    var timeRemaining = EndTime - timeNow;
    UpdateTimeRemaining(timeRemaining);

    var timeElapsed = timeNow - StartTime;
    var totalDuration = EndTime - StartTime;
    var percentageElapsed = (timeElapsed.TotalMilliseconds/totalDuration.TotalMilliseconds)*100;

    if (percentageElapsed > 0)
    {
        Progress.Value = (int) percentageElapsed;
    }
    else
    {
        Progress.Value = 0;
    }
}

录音操作本身是通过录制按钮的 `Click` 事件处理程序调用的。

private async void StartRecording_Click(object sender, EventArgs e)
{
    StartRecording();

    await RecordingCompletedSignal.WaitAsync();

    StopRecording();
}

您可能已经注意到了 `StartRecording_Click` 并想知道为什么它被实现为一个 `async` 方法。您可能还想知道一个返回 `void` 的方法变成 `async` 的优点,尤其是考虑到“避免 `async void`”的普遍看法。这实际上并没有初看起来那么令人担忧,避免 `async void` 的规则唯一的例外是当方法是事件处理程序时,而这正是我们在这里处理的。`StartRecording_Click` 是一个事件处理程序,这意味着它是一个 `void` 方法可能不足为奇,将其变成一个 `async` 方法是完全可以的。

`StartRecording()` 方法利用可选的回调函数,作为 `InitiateMP3StreamProcess` 方法的第二个参数提供,形式为一个匿名方法,该方法又调用 `RecordingCompletedSignal.Release()`。事件序列如下:当录音时长到期或用户手动单击停止按钮时,认为录音已完成。在任何一种情况下,都会向外部进程发送一个退出消息,允许它完成录音并正常关闭,并在退出时调用 `RecordingCompletedSignal.Release()`。这样做会触发 `RecordingCompletedSignal` 信号量,我们使用 `WaitAsync()` 异步等待它。

private void StartRecording()
{
    var configuration =
        $"-device=\"{Devices.SelectedItem}\" " +
        $"-line=\"{Lines.SelectedItem}\" " +
        $"-v={Volume.Text} " +
        $"-br={BitRate.Text} -sr=32000";

    RecordingProcess = 
        InitiateMp3StreamProcess(configuration, (s, e) => RecordingCompletedSignal.Release());

    StartRecordingButton.Enabled = false;
    StopRecordingButton.Enabled = true;

    UseTimedRecordingIfNecessary();
}

使用这种协调的异步机制的替代方法是直接调用 `Process.Kill()`,实际上这个工具的早期版本就是这样工作的。这带来了一些需要解决的问题。例如,我发现使用 `Process.Kill()` 会导致使用mp3_stream.exe 制作的录音总时长总是比预期短大约 3 秒,我需要为此预留时间。这可能是因为编码过程在被突然终止时丢弃了其缓冲区末尾的一小块数据。

private void StopRecording()
{
    RecordingProcess.WaitForExit();
    RecordingProcess.Close();
    SaveRecording();

    UpdateTimeRemaining(new TimeSpan(0, 0, 0));

    Progress.Style = ProgressBarStyle.Continuous;
    Progress.Value = 100;

    StartRecordingButton.Enabled = true;
    StopRecordingButton.Enabled = false;
}

无法直接等待回调方法,只有 `Task` 才能真正等待。为了实现这一点,回调函数通过一个匿名方法 `(s, e) => RecordingCompletedSignal.Release()` 使用 `RecordingCompletedSignal`,该方法在mp3_stream.exe 进程完成时调用。这意味着 `StartRecording_Click` 现在可以等待 `SemaphoreSlim.WaitAsync()` 返回的 `Task` 对象。

为了实现这一点,在mp3_stream 外部应用程序本身也存在一些需要克服的复杂问题,它正在轮询控制台输入以获取退出信号,采用“按任意键退出”的样式。这里的问题是无法重定向控制台输入,只有标准输入才能被这样重定向。将退出键按到外部应用程序需要获取一个不安全句柄并直接将键字符复制到其缓冲区。我通过修改mp3_stream 并用读取标准输入的 `cin >>` 替换 `_kbhit()` 调用来避免所有这些复杂性。

通过更新代码以利用 `async` 方法、回调函数、信号量等,现在可以更优雅地结束mp3_stream.exe,并等待回调以接收其完成通知。

保存和检索配置

为了增加便利性,该工具会在退出时自动保存当前配置。下次启动该工具时,该配置将被重新加载。这是通过处理窗体的 `FormClosing` 和 `Load` 事件实现的。

private void Record_FormClosing(object sender, FormClosingEventArgs e)
{
    SaveCurrentConfiguration();
}

private void Record_Load(object sender, EventArgs e)
{
    RestorePreviousConfiguration();
}

`SaveCurrentConfiguration` 和 `RestorePreviousConfiguration` 方法依次使用 `Settings` 类来访问应用程序配置参数。

private void RestorePreviousConfiguration()
{
    var config = Settings.Default;
    Volume.Text = config.Volume;
    BitRate.Text = config.BitRate;
    FileName.Text = config.FileName;

    if (Devices.Items.Contains(config.Device))
    {
        Devices.SelectedItem = config.Device;
    }
    if (Lines.Items.Contains(config.Line))
    {
        Lines.SelectedItem = config.Line;
    }
}

private void SaveCurrentConfiguration()
{
    var config = Settings.Default;
    config.Volume = Volume.Text;
    config.BitRate = BitRate.Text;
    config.FileName = FileName.Text;

    if (Devices.SelectedItem != null)
    {
        config.Device = Devices.SelectedItem.ToString();
    }
    if (Lines.SelectedItem != null)
    {
        config.Line = Lines.SelectedItem.ToString();
    }
            
    config.Save();
}

功能请求

自本文首次发布以来,已有几个人联系我,要求增强该实用工具以支持新功能。每次合并新功能或增强功能时,我都会在此添加新的子章节。请随时提出您的请求。我将尽我所能,尽快满足尽可能多的请求。

自动文件名

一个受欢迎的请求是添加一项增强功能,以便可以使用同一个文件名多次。看来,使用该实用工具的一个常见方法是让该实用工具保持运行,并连续执行多次录音。在录音之间必须反复指定新文件名会打破这种使用模式的流程。因此,许多增强请求都围绕着如何实现这一点。

一个建议是将音频内容附加到现有文件中,从而延长其长度。这个建议的一个潜在问题是某些音频播放器(例如 Windows Media Player)在首次打开或将其添加到内部库时似乎会缓存音频文件的长度。这似乎阻止了这些播放器识别音频文件的长度已更改,因此一旦原始长度过去,它们就会停止播放音轨。

另一个建议是用新文件覆盖预先存在的文件,但这有很多复杂性。例如,用户可能没有打算这样做。即使覆盖是故意的,它仍然可能存在问题。例如,现有文件可能已被另一个播放器或实用工具打开,因此此时无法覆盖它。

解决这些复杂问题的一种方法是根据原始文件名自动生成新文件名。最小化生成可能与其他现有文件名冲突的文件名的可能性也很有用。

该实用工具现在具有一项新功能,如果用户指定的名称已存在,则会自动生成一个新文件名。例如,如果用户指定 `test7.mp3` 作为文件名但该文件已存在,该实用工具现在会将新录音保存到一个名为,例如,`test7_auto_named_2013_12_29_14_08_36_372.mp3` 的文件中。自动生成的文件名会添加后缀 `_auto_named_`,后跟生成名称的*日期*和*时间*。日期和时间信息的顺序是:*年*、*月*、*日*、*小时*、*分钟*、*秒*和*毫秒*。这应该有助于最大程度地减少意外文件名冲突的可能性。

国际语言支持

此请求的出现是因为 aXu_AP 报告了一个 `music.mp3 文件未找到` 错误,然后我们发现问题在于运行该实用工具的机器使用了国际语言。在这种情况下,语言是芬兰语。我发现问题不在于实用工具本身,而在于它调用的mp3_stream.exe

我更改了mp3_stream.exe 的 C++ 代码并重新编译了它,使其现在对所有字符串操作使用宽字符。这似乎有所改善,并且*线路*的名称现在报告为 *Paavoimakkuus*,而不是之前报告的不可打印字符。然而,问题仍未解决,因为 *Paavoimakkuus* 中的 *aa* 字符实际上是错误的。*线路*名称本应是 *Päävoimakkuus*。然后我发现mp3_stream.exe 还硬编码了俄语作为编码语言,通过调用

setlocale( LC_ALL, ".866");

我再次修改了mp3_stream.exe,这次是为了让它拾取机器的默认语言。

setlocale( LC_ALL, "");

这解决了问题。 aXu_AP 非常好心地在同一台芬兰语机器上进行了测试,并提供了本节中的截图,包括这张截图,显示该实用工具现在确实支持国际语言。

最后...

我希望您觉得这个实用工具很有用。我本人已经大量使用了原始的mp3_stream.exe 应用程序,并认为通过发布这些可用性改进来回馈 CodeProject 是很好的。

历史

  • 2010 年 2 月 6 日
    • 初次发布
  • 2011 年 9 月 18 日
    • 添加了“Windows 7 可用性说明”章节
  • 2013 年 12 月 29 日
    • 添加了“功能请求 章节及其“自动文件名 子章节
  • 2015 年 3 月 29 日
    • 更新了截图,并更新了“Windows 7、8 和 8.1 可用性说明”章节
  • 2015 年 11 月 15 日
    • 更新了图标、截图和“Windows 7、8、8.1 和 10 可用性说明”章节
    • 移除了与旧版本 Visual Studio 兼容的解决方案文件(例如,`Record_VS10.sln`),因为现在最新版本的 Visual Studio Community 对所有人免费开放。
    • 更新了代码,现在以 .NET 4.6 和 C# 6 为目标,并利用了 async/await,这在“录音”章节中有讨论。
  • 2015 年 11 月 22 日
    • 更改了“另存为”行为,现在即使文件已存在也会自动生成文件名。
    • 将文件名持久化到配置设置存储中,同样是为了同一个原因。
    • 修复了持续时间计时器,它现在始终为 24 小时格式,无论机器设置如何。
    • 为进度条中的剩余时间消息添加了前导零填充,以获得更一致的外观。
    • 在 Windows 10 计算机上进行了测试,并用在该计算机上截取的屏幕截图更新了本文。
  • 2016 年 3 月 6 日
    • 修改并重新编译了mp3_stream.exe 以支持通过宽字符的国际语言。
  • 2016 年 5 月 22 日
    • 添加了“国际语言支持”章节,包括由 aXu_AP 慷慨提供的截图。
© . All rights reserved.