Commit 4e86662e authored by shrabbit's avatar shrabbit
Browse files

feat(ImageAudio): 重写为流式采样,新增 WAV/MP3 导出、参数解析和 Ctrl+C 停止,添加 NAudio.Lame 依赖

parent ead77415
Loading
Loading
Loading
Loading
+242 −76
Original line number Diff line number Diff line
using NAudio.Wave;
using NAudio.Lame;
using NAudio.Wave;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Advanced;
using SixLabors.ImageSharp.PixelFormats;
using Spectre.Console;
using TestV.Tools;
using Color = Spectre.Console.Color;

namespace TestV.Playgrounds;

@@ -11,107 +12,272 @@ public class ImageAudio : IPlayground
{
    public string LaunchCmd { get; } = "ia";
    public string? Name { get; } = "Image Audio";
    public string? Description { get; } = "将32位像素转换成音频采样";
    public string? Description { get; } = "将32位像素转换成音频采样(奇数像素→左声道,偶数像素→右声道,支持WAV/MP3输出、Ctrl+C停止)";

    private const int BitsPerPixel = 32;   // R/G/B/A 各 8 bit
    private const int Channels = 2;        // 立体声
    private const int DefaultSampleRate = 44100;
    private const int DefaultMp3BitRate = 128; // kbps

    public async Task Run(string[] args)
    {
        if (Errors.ValidArgsLength(args, 1))
        if (!Errors.ValidArgsLength(args, 1)) return;
        var opts = ParseOptions(args);
        if (opts is null) return;

        if (!File.Exists(opts.ImagePath))
        {
            AnsiConsole.MarkupLine("[red]文件不存在。[/]");
            return;
        }

        using var image = Image.Load<Rgba32>(opts.ImagePath);
        int pixelCount = image.Width * image.Height;
        long totalFrames = (long)pixelCount * BitsPerPixel;
        long totalSamples = totalFrames * Channels;
        long totalBytes = totalSamples * sizeof(float);

        AnsiConsole.MarkupLine($"[grey]尺寸: {image.Width}x{image.Height} = {pixelCount:N0} 像素[/]");
        AnsiConsole.MarkupLine($"[grey]采样: {totalSamples:N0} float ({new Storage(totalBytes)})[/]");
        AnsiConsole.MarkupLine($"[grey]时长: {TimeSpan.FromSeconds(totalFrames / (double)opts.SampleRate):g}[/]");

        if (opts.WavPath is not null)
        {
            WriteWav(image, opts.WavPath, opts.SampleRate);
            AnsiConsole.MarkupLine($"[green]WAV 已写入: {opts.WavPath}[/]");
        }

        if (!File.Exists(args[0]))
        if (opts.Mp3Path is not null)
        {
            AnsiConsole.MarkupLine("[red]文件不存在。[/]");
            return;
            WriteMp3(image, opts.Mp3Path, opts.SampleRate, opts.Mp3BitRate);
            AnsiConsole.MarkupLine($"[green]MP3 已写入: {opts.Mp3Path} ({opts.Mp3BitRate} kbps)[/]");
        }

        var sample = ToSample(Image.Load<Rgba32>(args[0]));
        Console.WriteLine($"SAMPLE: {sample.Length}");
        if (!opts.Play) return;

        PlayFloatArray(sample, 44100, 1);
        using var cts = new CancellationTokenSource();
        ConsoleCancelEventHandler handler = (_, e) => { e.Cancel = true; cts.Cancel(); };
        Console.CancelKeyPress += handler;
        try
        {
            AnsiConsole.MarkupLine("[grey]播放中... (Ctrl+C 停止)[/]");
            await PlayAsync(image, opts.SampleRate, opts.ShowColor, cts.Token);
            AnsiConsole.MarkupLine("\n[green]播放结束。[/]");
        }
        catch (OperationCanceledException)
        {
            AnsiConsole.MarkupLine("\n[yellow]已停止。[/]");
        }
        finally
        {
            Console.CancelKeyPress -= handler;
        }
    }

    public static void PlayFloatArray(float[] samples, int sampleRate, int channels)
    private sealed class Options
    {
        // 1. 将 float[] 转换为 byte[]
        byte[] buffer = new byte[samples.Length * sizeof(float)];
        Buffer.BlockCopy(samples, 0, buffer, 0, buffer.Length);
        public required string ImagePath { get; init; }
        public int SampleRate { get; set; } = DefaultSampleRate;
        public bool ShowColor { get; set; }
        public bool Play { get; set; } = true;
        public string? WavPath { get; set; }
        public string? Mp3Path { get; set; }
        public int Mp3BitRate { get; set; } = DefaultMp3BitRate;
    }

        // 2. 创建 RawSourceWaveStream(指定 32-bit Float 格式)
        var waveFormat = WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channels);
        using var stream = new MemoryStream(buffer);
        using var rawStream = new RawSourceWaveStream(stream, waveFormat);
    private static Options? ParseOptions(string[] args)
    {
        var opts = new Options { ImagePath = args[0] };
        for (int i = 1; i < args.Length; i++)
        {
            var a = args[i];
            if (a is "--color" or "-c")
                opts.ShowColor = true;
            else if (a is "--no-play")
                opts.Play = false;
            else if (a is "--wav" or "-w")
            {
                if (i + 1 >= args.Length) { Errors.NoArgs(1); return null; }
                opts.WavPath = args[++i];
            }
            else if (a.StartsWith("--wav="))
                opts.WavPath = a["--wav=".Length..];
            else if (a is "--mp3" or "-m")
            {
                if (i + 1 >= args.Length) { Errors.NoArgs(1); return null; }
                opts.Mp3Path = args[++i];
            }
            else if (a.StartsWith("--mp3="))
                opts.Mp3Path = a["--mp3=".Length..];
            else if (a.StartsWith("--rate=") && int.TryParse(a["--rate=".Length..], out var r))
                opts.SampleRate = r;
            else if (a.StartsWith("--bitrate=") && int.TryParse(a["--bitrate=".Length..], out var br))
                opts.Mp3BitRate = br;
            else
            {
                Errors.NotArrow("参数", "--color", "-c", "--no-play", "--wav", "-w", "--wav=", "--mp3", "-m", "--mp3=", "--rate=", "--bitrate=");
                return null;
            }
        }
        return opts;
    }

        // 3. 播放
        using var outputDevice = new WaveOutEvent();
        outputDevice.Init(rawStream);
        outputDevice.Play();
    /// <summary>
    /// 流式写入 WAV:仅用小块缓冲,不持有完整采样数组。
    /// </summary>
    private static void WriteWav(Image<Rgba32> image, string path, int sampleRate)
    {
        var provider = new PixelSampleProvider(image, sampleRate, showColor: false);
        using var writer = new WaveFileWriter(path, provider.WaveFormat);
        var buf = new float[8192];
        int read;
        while ((read = provider.Read(buf, 0, buf.Length)) > 0)
            writer.WriteSamples(buf, 0, read);
    }

        // 等待播放完成
        while (outputDevice.PlaybackState == PlaybackState.Playing)
    /// <summary>
    /// 流式写入 MP3:使用 LameMP3FileWriter,IEEE Float 输入内部转 16-bit 编码。
    /// </summary>
    private static void WriteMp3(Image<Rgba32> image, string path, int sampleRate, int bitRate)
    {
        var provider = new PixelSampleProvider(image, sampleRate, showColor: false);
        using var writer = new LameMP3FileWriter(path, provider.WaveFormat, bitRate);
        var floatBuf = new float[8192];
        var byteBuf = new byte[floatBuf.Length * sizeof(float)];
        int read;
        while ((read = provider.Read(floatBuf, 0, floatBuf.Length)) > 0)
        {
            Thread.Sleep(100);
            Buffer.BlockCopy(floatBuf, 0, byteBuf, 0, read * sizeof(float));
            writer.Write(byteBuf, 0, read * sizeof(float));
        }
    }

    private float[] ToSample(Image<Rgba32> image)
    /// <summary>
    /// 流式播放:NAudio 按需调用 Provider.Read,不预先生成全部采样。
    /// </summary>
    private static async Task PlayAsync(Image<Rgba32> image, int sampleRate, bool showColor, CancellationToken token)
    {
        Console.WriteLine($"w: {image.Width}, h: {image.Height}");
        var provider = new PixelSampleProvider(image, sampleRate, showColor);
        using var output = new WaveOutEvent();
        output.Init(provider);
        output.Play();

        var list = new List<float>();
        while (output.PlaybackState == PlaybackState.Playing)
        {
            try { await Task.Delay(50, token); }
            catch (OperationCanceledException) { break; }
        }
        output.Stop();
    }

        foreach (var pixel in GetPixels(image))
    /// <summary>
    /// 按需生成采样的 ISampleProvider。
    /// 奇数像素 → 左声道,偶数像素 → 右声道;每像素 32 个立体声帧。
    /// 仅缓存一行像素,避免一次性持有全部采样。
    /// </summary>
    private sealed class PixelSampleProvider : ISampleProvider
    {
            list.AddRange(PixelToSample(pixel));
        private readonly Image<Rgba32> _image;
        private readonly bool _showColor;
        private readonly Rgba32[] _rowBuf;
        private int _rowY;
        private int _x;
        private int _bitInPixel;
        private long _pixelIdx;
        private Rgba32 _current;
        private bool _toLeft;
        private bool _colorWritten;

        public WaveFormat WaveFormat { get; }

        public PixelSampleProvider(Image<Rgba32> image, int sampleRate, bool showColor)
        {
            _image = image;
            _showColor = showColor;
            WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, Channels);
            _rowBuf = new Rgba32[image.Width];
            _rowY = 0;
            _x = 0;
            LoadRow(0);
            SetCurrent();
        }

        return list.ToArray();
        private void LoadRow(int y)
        {
            _image.ProcessPixelRows(accessor => accessor.GetRowSpan(y).CopyTo(_rowBuf));
        }

    public static Rgba32[] GetPixels(Image<Rgba32> image)
        private void SetCurrent()
        {
        int width = image.Width;
        int height = image.Height;
        var pixels = new Rgba32[width * height];
            _current = _rowBuf[_x];
            _toLeft = (_pixelIdx & 1) != 0;
            _colorWritten = false;
            _bitInPixel = 0;
        }

        image.ProcessPixelRows(accessor =>
        private bool Advance()
        {
            for (int y = 0; y < height; y++)
            _bitInPixel++;
            if (_bitInPixel < BitsPerPixel) return true;

            _pixelIdx++;
            _x++;
            if (_x >= _image.Width)
            {
                Span<Rgba32> row = accessor.GetRowSpan(y);
                // 将整行像素拷贝到数组对应位置
                row.CopyTo(pixels.AsSpan(y * width, width));
                _x = 0;
                _rowY++;
                if (_rowY >= _image.Height) return false;
                LoadRow(_rowY);
            }
        });

        return pixels;
            SetCurrent();
            return true;
        }

    private float[] PixelToSample(Rgba32 pixel)
        public int Read(float[] buffer, int offset, int count)
        {
            int written = 0;
            int limit = count - (Channels - 1); // 保证能写满一个立体声帧
            while (written < limit)
            {
                if (_rowY >= _image.Height) break;

                if (_showColor && !_colorWritten)
                {
        var arr = new float[32];
        Array.Copy(ChannelToSample(pixel.R), 0, arr, 0, 8);
        Array.Copy(ChannelToSample(pixel.G), 0, arr, 8, 8);
        Array.Copy(ChannelToSample(pixel.B), 0, arr, 16, 8);
        Array.Copy(ChannelToSample(pixel.A), 0, arr, 24, 8);
        return arr;
                    AnsiConsole.Write(new Text(" ", new Style(null, new Color(_current.R, _current.G, _current.B))));
                    _colorWritten = true;
                }

    private float[] ChannelToSample(byte channel)
                float v = GetBitValue(_current, _bitInPixel);
                int idx = offset + written;
                if (_toLeft)
                {
        return
        [
            GetBit(channel, 0),
            GetBit(channel, 1),
            GetBit(channel, 2),
            GetBit(channel, 3),
            GetBit(channel, 4),
            GetBit(channel, 5),
            GetBit(channel, 6),
            GetBit(channel, 7),
            GetBit(channel, 7),
        ];
                    buffer[idx] = v;
                    buffer[idx + 1] = 0f;
                }
                else
                {
                    buffer[idx] = 0f;
                    buffer[idx + 1] = v;
                }
                written += Channels;

    private float GetBit(byte value, int n) => ((value >> n & 1) != 0) ? 1f : 0f;
                if (!Advance()) break;
            }
            return written;
        }

        private static float GetBitValue(Rgba32 px, int bit)
        {
            // bit 0..7: R, 8..15: G, 16..23: B, 24..31: A
            // 二进制 0 → -1,二进制 1 → 1,使波形在 [-1, 1] 间波动
            byte channel = (bit >> 3) switch
            {
                0 => px.R,
                1 => px.G,
                2 => px.B,
                _ => px.A,
            };
            return ((channel >> (bit & 7)) & 1) != 0 ? 1f : -1f;
        }
    }
}
+3 −1
Original line number Diff line number Diff line
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
@@ -14,6 +14,8 @@
    
      <PackageReference Include="NAudio" Version="2.3.0" />

      <PackageReference Include="NAudio.Lame" Version="2.1.0" />
    
      <PackageReference Include="RadLine" Version="0.10.0" />
    
      <PackageReference Include="Spectre.Console" Version="0.55.2" />