Commit a2fc9e49 authored by shrabbit's avatar shrabbit
Browse files

feat(AudioImage/ImageAudio): 新增 --algo 参数切换可逆/不可逆算法,重构采样提供器并支持 32 位位映射

parent 51532752
Loading
Loading
Loading
Loading
+123 −13
Original line number Diff line number Diff line
@@ -14,9 +14,16 @@ public class AudioImage : IPlayground
{
    public string LaunchCmd { get; } = "ai";
    public string? Name { get; } = "Audio Image";
    public string? Description { get; } = "将音频采样反向变换为图片(可逆转换,默认PNG,可选JPG/BMP,可设置宽度或高度)";
    public string? Description { get; } = "将音频采样反向变换为图片(支持可逆/不可逆算法,默认PNG,可选JPG/BMP,可设置宽度或高度)";

    private const int Channels = 2;        // 立体声(与 ImageAudio 对应)
    private const int BitsPerPixel = 32;   // 不可逆算法:R/G/B/A 各 8 bit

    public enum Algorithm
    {
        Reversible, // 字节级直接映射,无损可逆
        Lossy,      // 位级映射,振幅压缩为 ±1,不可逆
    }

    public async Task Run(string[] args)
    {
@@ -46,6 +53,7 @@ public class AudioImage : IPlayground
        public string Format { get; set; } = "png";
        public int? Width { get; set; }
        public int? Height { get; set; }
        public Algorithm Algo { get; set; } = Algorithm.Reversible;
    }

    private static Options? ParseOptions(string[] args)
@@ -75,9 +83,20 @@ public class AudioImage : IPlayground
                opts.Width = w;
            else if (a.StartsWith("--height=") && int.TryParse(a["--height=".Length..], out var h) && h > 0)
                opts.Height = h;
            else if (a is "--algo" or "-a")
            {
                if (i + 1 >= args.Length) { Errors.NoArgs(1); return null; }
                if (!TryParseAlgo(args[++i], out var algo)) { Errors.NotArrow("算法", "reversible", "lossy"); return null; }
                opts.Algo = algo;
            }
            else if (a.StartsWith("--algo="))
            {
                if (!TryParseAlgo(a["--algo=".Length..], out var algo)) { Errors.NotArrow("算法", "reversible", "lossy"); return null; }
                opts.Algo = algo;
            }
            else
            {
                Errors.NotArrow("参数", "--out", "-o", "--out=", "--format", "--format=", "--width=", "--height=");
                Errors.NotArrow("参数", "--out", "-o", "--out=", "--format", "--format=", "--width=", "--height=", "--algo", "-a", "--algo=");
                return null;
            }
        }
@@ -98,21 +117,42 @@ public class AudioImage : IPlayground
        return false;
    }

    private static bool TryParseAlgo(string s, out Algorithm algo)
    {
        algo = default;
        if (s.Equals("reversible", StringComparison.OrdinalIgnoreCase) ||
            s.Equals("rev", StringComparison.OrdinalIgnoreCase))
        { algo = Algorithm.Reversible; return true; }
        if (s.Equals("lossy", StringComparison.OrdinalIgnoreCase))
        { algo = Algorithm.Lossy; return true; }
        return false;
    }

    private void Decode(Options opts)
    {
        using var reader = new AudioFileReader(opts.AudioPath);
        int audioChannels = reader.WaveFormat.Channels;

        if (audioChannels != 2)
        // 可逆算法要求立体声;不可逆算法支持任意声道
        if (opts.Algo == Algorithm.Reversible && audioChannels != 2)
        {
            AnsiConsole.MarkupLine($"[red]仅支持立体声音频(当前: {audioChannels} 声道)。ImageAudio 生成的音频为立体声。[/]");
            AnsiConsole.MarkupLine($"[red]可逆算法仅支持立体声音频(当前: {audioChannels} 声道)。[/]");
            return;
        }
        if (audioChannels < 1)
        {
            AnsiConsole.MarkupLine("[red]无效的音频声道数。[/]");
            return;
        }

        // 每2个float采样=1个立体声帧=2个像素
        long totalSamples = reader.Length / sizeof(float);
        long totalFrames = totalSamples / Channels;
        long totalPixels = totalFrames * 2;
        long totalFrames = totalSamples / audioChannels;

        long totalPixels;
        if (opts.Algo == Algorithm.Reversible)
            totalPixels = totalFrames * 2;              // 每2像素=1帧
        else
            totalPixels = totalFrames / BitsPerPixel;   // 每像素=32帧

        if (totalPixels <= 0)
        {
@@ -123,12 +163,28 @@ public class AudioImage : IPlayground
        var (width, height) = ComputeDimensions(totalPixels, opts.Width, opts.Height);
        long usedPixels = (long)width * height;

        AnsiConsole.MarkupLine($"[grey]算法: {opts.Algo}[/]");
        AnsiConsole.MarkupLine($"[grey]音频: {Path.GetFileName(opts.AudioPath)} ({audioChannels}ch, {reader.WaveFormat.SampleRate}Hz)[/]");
        AnsiConsole.MarkupLine($"[grey]可用像素: {totalPixels:N0}[/]");
        AnsiConsole.MarkupLine($"[grey]输出尺寸: {width}x{height} = {usedPixels:N0} 像素" +
                               (usedPixels < totalPixels ? $" [yellow](丢弃 {totalPixels - usedPixels:N0} 像素采样)[/]" : "") + "[/]");

        using var image = new Image<Rgba32>(width, height);

        if (opts.Algo == Algorithm.Reversible)
            DecodeReversible(reader, image, width, height, usedPixels);
        else
            DecodeLossy(reader, image, width, height, usedPixels, audioChannels);

        Save(image, opts.OutputPath, opts.Format);
        AnsiConsole.MarkupLine($"[green]已写入: {opts.OutputPath} ({opts.Format})[/]");
    }

    /// <summary>
    /// 可逆算法:每帧 [left,right] → 偶数像素=right,奇数像素=left
    /// </summary>
    private static void DecodeReversible(AudioFileReader reader, Image<Rgba32> image, int width, int height, long usedPixels)
    {
        var sampleBuf = new float[8192];
        int bufPos = 0;
        int bufLen = 0;
@@ -141,7 +197,6 @@ public class AudioImage : IPlayground

            while (pixelIdx < usedPixels)
            {
                // 确保缓冲区有至少2个采样(1帧)
                if (bufPos + 2 > bufLen)
                {
                    int remaining = bufLen - bufPos;
@@ -152,7 +207,6 @@ public class AudioImage : IPlayground
                    if (bufLen < 2) break;
                }

                // 立体声帧:[left, right]
                float left = sampleBuf[bufPos];
                float right = sampleBuf[bufPos + 1];
                bufPos += 2;
@@ -174,9 +228,65 @@ public class AudioImage : IPlayground
                pixelIdx++;
            }
        });
    }

        Save(image, opts.OutputPath, opts.Format);
        AnsiConsole.MarkupLine($"[green]已写入: {opts.OutputPath} ({opts.Format})[/]");
    /// <summary>
    /// 不可逆算法:每 32 帧 → 1 像素;采样 > 0 → bit 1,否则 → bit 0
    /// 奇数像素取左声道,偶数像素取右声道
    /// </summary>
    private static void DecodeLossy(AudioFileReader reader, Image<Rgba32> image, int width, int height, long usedPixels, int audioChannels)
    {
        long totalFrames = (long)usedPixels * BitsPerPixel;
        long totalSamples = totalFrames * audioChannels;
        var buf = new float[width * BitsPerPixel * audioChannels];
        long pixelIdx = 0;
        int y = 0;

        while (pixelIdx < usedPixels && y < height)
        {
            int toRead = (int)Math.Min(buf.Length, totalSamples - pixelIdx * BitsPerPixel * audioChannels);
            if (toRead <= 0) break;
            int read = reader.Read(buf, 0, Math.Min(toRead, buf.Length));
            if (read == 0) break;
            if (read < buf.Length) Array.Clear(buf, read, buf.Length - read);

            image.ProcessPixelRows(accessor =>
            {
                var row = accessor.GetRowSpan(y);
                int cols = Math.Min(width, (int)(usedPixels - pixelIdx));
                for (int x = 0; x < cols; x++)
                {
                    row[x] = DecodePixelLossy(buf, x * BitsPerPixel, audioChannels, pixelIdx + x);
                }
            });

            pixelIdx += width;
            y++;
        }
    }

    private static Rgba32 DecodePixelLossy(float[] buf, int frameBase, int audioChannels, long pixelIdx)
    {
        bool useLeft = (pixelIdx & 1) != 0;
        int channelOffset = useLeft ? 0 : (audioChannels >= 2 ? 1 : 0);

        byte r = 0, g = 0, b = 0, a = 0;
        for (int j = 0; j < BitsPerPixel; j++)
        {
            float v = buf[(frameBase + j) * audioChannels + channelOffset];
            if (v > 0f)
            {
                byte mask = (byte)(1 << (j & 7));
                switch (j >> 3)
                {
                    case 0: r |= mask; break;
                    case 1: g |= mask; break;
                    case 2: b |= mask; break;
                    case 3: a |= mask; break;
                }
            }
        }
        return new Rgba32(r, g, b, a);
    }

    private static (int width, int height) ComputeDimensions(long totalPixels, int? reqWidth, int? reqHeight)
@@ -197,7 +307,7 @@ public class AudioImage : IPlayground
    }

    /// <summary>
    /// float → RGBA 4字节(与 ImageAudio.PixelToFloat 互逆)
    /// float → RGBA 4字节(与 ImageAudio.ReversiblePixelProvider.PixelToFloat 互逆)
    /// </summary>
    private static Rgba32 FloatToPixel(float v)
    {
+166 −45
Original line number Diff line number Diff line
@@ -12,11 +12,18 @@ public class ImageAudio : IPlayground
{
    public string LaunchCmd { get; } = "ia";
    public string? Name { get; } = "Image Audio";
    public string? Description { get; } = "将像素RGBA字节直接作为音频采样(奇数像素→左声道,偶数像素→右声道,可逆转换,支持WAV/MP3输出、Ctrl+C停止)";
    public string? Description { get; } = "将像素转换为音频采样(奇数像素→左声道,偶数像素→右声道,支持可逆/不可逆算法、WAV/MP3输出、Ctrl+C停止)";

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

    public enum Algorithm
    {
        Reversible, // 字节级直接映射,无损可逆
        Lossy,      // 位级映射,振幅压缩为 ±1,不可逆
    }

    public async Task Run(string[] args)
    {
@@ -35,24 +42,30 @@ public class ImageAudio : IPlayground

        using var image = Image.Load<Rgba32>(opts.ImagePath);
        int pixelCount = image.Width * image.Height;
        // 每2个像素=1个立体声帧(偶数像素→右声道,奇数像素→左声道)
        long totalFrames = (pixelCount + 1) / 2;

        long totalFrames;
        if (opts.Algo == Algorithm.Reversible)
            totalFrames = (pixelCount + 1) / 2;        // 每2像素=1帧
        else
            totalFrames = (long)pixelCount * BitsPerPixel; // 每像素=32帧

        long totalSamples = totalFrames * Channels;
        long totalBytes = totalSamples * sizeof(float);

        AnsiConsole.MarkupLine($"[grey]算法: {opts.Algo}[/]");
        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);
            WriteWav(image, opts.WavPath, opts.SampleRate, opts.Algo);
            AnsiConsole.MarkupLine($"[green]WAV 已写入: {opts.WavPath}[/]");
        }

        if (opts.Mp3Path is not null)
        {
            WriteMp3(image, opts.Mp3Path, opts.SampleRate, opts.Mp3BitRate);
            WriteMp3(image, opts.Mp3Path, opts.SampleRate, opts.Mp3BitRate, opts.Algo);
            AnsiConsole.MarkupLine($"[green]MP3 已写入: {opts.Mp3Path} ({opts.Mp3BitRate} kbps)[/]");
        }

@@ -64,7 +77,7 @@ public class ImageAudio : IPlayground
        try
        {
            AnsiConsole.MarkupLine("[grey]播放中... (Ctrl+C 停止)[/]");
            await PlayAsync(image, opts.SampleRate, opts.ShowColor, cts.Token);
            await PlayAsync(image, opts.SampleRate, opts.ShowColor, opts.Algo, cts.Token);
            AnsiConsole.MarkupLine("\n[green]播放结束。[/]");
        }
        catch (OperationCanceledException)
@@ -96,6 +109,7 @@ public class ImageAudio : IPlayground
        public string? WavPath { get; set; }
        public string? Mp3Path { get; set; }
        public int Mp3BitRate { get; set; } = DefaultMp3BitRate;
        public Algorithm Algo { get; set; } = Algorithm.Reversible;
    }

    private static Options? ParseOptions(string[] args)
@@ -126,21 +140,45 @@ public class ImageAudio : IPlayground
                opts.SampleRate = r;
            else if (a.StartsWith("--bitrate=") && int.TryParse(a["--bitrate=".Length..], out var br))
                opts.Mp3BitRate = br;
            else if (a is "--algo" or "-a")
            {
                if (i + 1 >= args.Length) { Errors.NoArgs(1); return null; }
                if (!TryParseAlgo(args[++i], out var algo)) { Errors.NotArrow("算法", "reversible", "lossy"); return null; }
                opts.Algo = algo;
            }
            else if (a.StartsWith("--algo="))
            {
                if (!TryParseAlgo(a["--algo=".Length..], out var algo)) { Errors.NotArrow("算法", "reversible", "lossy"); return null; }
                opts.Algo = algo;
            }
            else
            {
                Errors.NotArrow("参数", "--color", "-c", "--no-play", "--wav", "-w", "--wav=", "--mp3", "-m", "--mp3=", "--rate=", "--bitrate=");
                Errors.NotArrow("参数", "--color", "-c", "--no-play", "--wav", "-w", "--wav=", "--mp3", "-m", "--mp3=", "--rate=", "--bitrate=", "--algo", "-a", "--algo=");
                return null;
            }
        }
        return opts;
    }

    /// <summary>
    /// 流式写入 WAV:仅用小块缓冲,不持有完整采样数组。
    /// </summary>
    private static void WriteWav(Image<Rgba32> image, string path, int sampleRate)
    private static bool TryParseAlgo(string s, out Algorithm algo)
    {
        algo = default;
        if (s.Equals("reversible", StringComparison.OrdinalIgnoreCase) ||
            s.Equals("rev", StringComparison.OrdinalIgnoreCase))
        { algo = Algorithm.Reversible; return true; }
        if (s.Equals("lossy", StringComparison.OrdinalIgnoreCase))
        { algo = Algorithm.Lossy; return true; }
        return false;
    }

    private static ISampleProvider CreateProvider(Image<Rgba32> image, int sampleRate, bool showColor, Algorithm algo)
        => algo == Algorithm.Reversible
            ? new ReversiblePixelProvider(image, sampleRate, showColor)
            : new LossyPixelProvider(image, sampleRate, showColor);

    private static void WriteWav(Image<Rgba32> image, string path, int sampleRate, Algorithm algo)
    {
        var provider = new PixelSampleProvider(image, sampleRate, showColor: false);
        var provider = CreateProvider(image, sampleRate, false, algo);
        using var writer = new WaveFileWriter(path, provider.WaveFormat);
        var buf = new float[8192];
        int read;
@@ -148,12 +186,9 @@ public class ImageAudio : IPlayground
            writer.WriteSamples(buf, 0, read);
    }

    /// <summary>
    /// 流式写入 MP3:使用 LameMP3FileWriter,IEEE Float 输入内部转 16-bit 编码。
    /// </summary>
    private static void WriteMp3(Image<Rgba32> image, string path, int sampleRate, int bitRate)
    private static void WriteMp3(Image<Rgba32> image, string path, int sampleRate, int bitRate, Algorithm algo)
    {
        var provider = new PixelSampleProvider(image, sampleRate, showColor: false);
        var provider = CreateProvider(image, sampleRate, false, algo);
        using var writer = new LameMP3FileWriter(path, provider.WaveFormat, bitRate);
        var floatBuf = new float[8192];
        var byteBuf = new byte[floatBuf.Length * sizeof(float)];
@@ -165,12 +200,9 @@ public class ImageAudio : IPlayground
        }
    }

    /// <summary>
    /// 流式播放:NAudio 按需调用 Provider.Read,不预先生成全部采样。
    /// </summary>
    private static async Task PlayAsync(Image<Rgba32> image, int sampleRate, bool showColor, CancellationToken token)
    private static async Task PlayAsync(Image<Rgba32> image, int sampleRate, bool showColor, Algorithm algo, CancellationToken token)
    {
        var provider = new PixelSampleProvider(image, sampleRate, showColor);
        var provider = CreateProvider(image, sampleRate, showColor, algo);
        using var output = new WaveOutEvent();
        output.Init(provider);
        output.Play();
@@ -184,38 +216,32 @@ public class ImageAudio : IPlayground
    }

    /// <summary>
    /// 可逆映射:像素的 RGBA 4字节直接作为一个 float 采样的4字节。
    /// 偶数像素 → 右声道,奇数像素 → 左声道;每2个像素产生1个立体声帧。
    /// 仅缓存一行像素,避免一次性持有全部采样。
    /// 共用:按行加载像素到缓冲。
    /// </summary>
    private sealed class PixelSampleProvider : ISampleProvider
    private abstract class PixelProviderBase : ISampleProvider
    {
        private readonly Image<Rgba32> _image;
        private readonly bool _showColor;
        private readonly Rgba32[] _rowBuf;
        private int _rowY;
        private int _x;
        private long _pixelIdx;
        protected readonly Image<Rgba32> _image;
        protected readonly Rgba32[] _rowBuf;
        protected int _rowY;
        protected int _x;
        protected long _pixelIdx;

        public WaveFormat WaveFormat { get; }

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

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

        private bool AdvancePixel()
        protected bool AdvancePixel()
        {
            _pixelIdx++;
            _x++;
@@ -229,7 +255,24 @@ public class ImageAudio : IPlayground
            return true;
        }

        public int Read(float[] buffer, int offset, int count)
        public abstract int Read(float[] buffer, int offset, int count);
    }

    /// <summary>
    /// 可逆算法:像素 RGBA 4字节直接作为一个 float 采样的4字节。
    /// 偶数像素 → 右声道,奇数像素 → 左声道;每2个像素产生1个立体声帧。
    /// </summary>
    private sealed class ReversiblePixelProvider : PixelProviderBase
    {
        private readonly bool _showColor;

        public ReversiblePixelProvider(Image<Rgba32> image, int sampleRate, bool showColor)
            : base(image, sampleRate)
        {
            _showColor = showColor;
        }

        public override int Read(float[] buffer, int offset, int count)
        {
            int written = 0;
            while (written + 1 < count)
@@ -245,7 +288,6 @@ public class ImageAudio : IPlayground

                if (!AdvancePixel())
                {
                    // 没有奇数像素了,只有右声道
                    buffer[offset + written++] = 0f;
                    buffer[offset + written++] = right;
                    break;
@@ -265,7 +307,6 @@ public class ImageAudio : IPlayground
                if (_showColor)
                    AnsiConsole.Write(new Text(" ", new Style(null, new Color(oddPixel.R, oddPixel.G, oddPixel.B))));

                // 立体声帧顺序:[left, right]
                buffer[offset + written++] = left;
                buffer[offset + written++] = right;

@@ -274,13 +315,93 @@ public class ImageAudio : IPlayground
            return written;
        }

        /// <summary>
        /// RGBA 4字节 → float(小端序,与 BitConverter.Int32BitsToSingle 对应)
        /// </summary>
        private static float PixelToFloat(Rgba32 px)
        {
            int bits = px.R | (px.G << 8) | (px.B << 16) | (px.A << 24);
            return BitConverter.Int32BitsToSingle(bits);
        }
    }

    /// <summary>
    /// 不可逆算法(位映射):像素 32 bit 各自映射为 ±1 采样。
    /// 奇数像素 → 左声道,偶数像素 → 右声道;每像素 32 个立体声帧。
    /// </summary>
    private sealed class LossyPixelProvider : PixelProviderBase
    {
        private readonly bool _showColor;
        private int _bitInPixel;
        private bool _toLeft;
        private bool _colorWritten;
        private Rgba32 _current;

        public LossyPixelProvider(Image<Rgba32> image, int sampleRate, bool showColor)
            : base(image, sampleRate)
        {
            _showColor = showColor;
            SetCurrent();
        }

        private void SetCurrent()
        {
            _current = _rowBuf[_x];
            _toLeft = (_pixelIdx & 1) != 0;
            _colorWritten = false;
            _bitInPixel = 0;
        }

        private bool Advance()
        {
            _bitInPixel++;
            if (_bitInPixel < BitsPerPixel) return true;

            if (!AdvancePixel()) return false;
            SetCurrent();
            return true;
        }

        public override 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)
                {
                    AnsiConsole.Write(new Text(" ", new Style(null, new Color(_current.R, _current.G, _current.B))));
                    _colorWritten = true;
                }

                float v = GetBitValue(_current, _bitInPixel);
                int idx = offset + written;
                if (_toLeft)
                {
                    buffer[idx] = v;
                    buffer[idx + 1] = 0f;
                }
                else
                {
                    buffer[idx] = 0f;
                    buffer[idx + 1] = v;
                }
                written += Channels;

                if (!Advance()) break;
            }
            return written;
        }

        private static float GetBitValue(Rgba32 px, int bit)
        {
            byte channel = (bit >> 3) switch
            {
                0 => px.R,
                1 => px.G,
                2 => px.B,
                _ => px.A,
            };
            return ((channel >> (bit & 7)) & 1) != 0 ? 1f : -1f;
        }
    }
}