Commit 51532752 authored by shrabbit's avatar shrabbit
Browse files

feat(AudioImage/ImageAudio): 将音频与图像互转改为 RGBA 字节直接映射,实现可逆转换,并检查输出文件是否已存在

parent 8d1d0a11
Loading
Loading
Loading
Loading
+62 −53
Original line number Diff line number Diff line
@@ -14,10 +14,9 @@ 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 BitsPerPixel = 32;   // 与 ImageAudio 对应:R/G/B/A 各 8 bit
    private const int FramesPerPixel = BitsPerPixel;
    private const int Channels = 2; // 立体声(与 ImageAudio 对应)

    public async Task Run(string[] args)
    {
@@ -31,6 +30,12 @@ public class AudioImage : IPlayground
            return;
        }

        if (File.Exists(opts.OutputPath))
        {
            AnsiConsole.MarkupLine($"[red]输出文件已存在: {opts.OutputPath}[/]");
            return;
        }

        await Task.Run(() => Decode(opts));
    }

@@ -46,7 +51,6 @@ public class AudioImage : IPlayground
    private static Options? ParseOptions(string[] args)
    {
        var audioPath = args[0];
        // 默认输出路径:与音频同目录,扩展名 png
        string outPath = Path.ChangeExtension(audioPath, ".png");
        var opts = new Options { AudioPath = audioPath, OutputPath = outPath };

@@ -78,7 +82,6 @@ public class AudioImage : IPlayground
            }
        }

        // 若未显式指定 --format,按输出扩展名推断
        if (!IsFormatArg(args))
        {
            var ext = Path.GetExtension(opts.OutputPath).TrimStart('.').ToLowerInvariant();
@@ -99,15 +102,17 @@ public class AudioImage : IPlayground
    {
        using var reader = new AudioFileReader(opts.AudioPath);
        int audioChannels = reader.WaveFormat.Channels;
        if (audioChannels < 1)

        if (audioChannels != 2)
        {
            AnsiConsole.MarkupLine("[red]无效的音频声道数。[/]");
            AnsiConsole.MarkupLine($"[red]仅支持立体声音频(当前: {audioChannels} 声道)。ImageAudio 生成的音频为立体声。[/]");
            return;
        }

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

        if (totalPixels <= 0)
        {
@@ -124,28 +129,51 @@ public class AudioImage : IPlayground
                               (usedPixels < totalPixels ? $" [yellow](丢弃 {totalPixels - usedPixels:N0} 像素采样)[/]" : "") + "[/]");

        using var image = new Image<Rgba32>(width, height);
        var rowBuf = new float[width * FramesPerPixel * audioChannels];
        long decodedPixels = 0;
        var sampleBuf = new float[8192];
        int bufPos = 0;
        int bufLen = 0;
        long pixelIdx = 0;

        for (int y = 0; y < height; y++)
        image.ProcessPixelRows(accessor =>
        {
            int read = reader.Read(rowBuf, 0, rowBuf.Length);
            if (read < rowBuf.Length)
                Array.Clear(rowBuf, read, rowBuf.Length - read);
            int curY = -1;
            Span<Rgba32> curRow = default;

            image.ProcessPixelRows(accessor =>
            while (pixelIdx < usedPixels)
            {
                var row = accessor.GetRowSpan(y);
                for (int x = 0; x < width; x++)
                // 确保缓冲区有至少2个采样(1帧)
                if (bufPos + 2 > bufLen)
                {
                    long pixelIdx = (long)y * width + x;
                    row[x] = DecodePixel(rowBuf, x * FramesPerPixel, audioChannels, pixelIdx);
                    int remaining = bufLen - bufPos;
                    if (remaining > 0)
                        Array.Copy(sampleBuf, bufPos, sampleBuf, 0, remaining);
                    bufPos = 0;
                    bufLen = remaining + reader.Read(sampleBuf, remaining, sampleBuf.Length - remaining);
                    if (bufLen < 2) break;
                }
            });

            decodedPixels += width;
            if (read < rowBuf.Length) break; // EOF:剩余行保持默认(透明)
                // 立体声帧:[left, right]
                float left = sampleBuf[bufPos];
                float right = sampleBuf[bufPos + 1];
                bufPos += 2;

                // 偶数像素 → right
                int y0 = (int)(pixelIdx / width);
                int x0 = (int)(pixelIdx % width);
                if (y0 != curY) { curY = y0; curRow = accessor.GetRowSpan(y0); }
                curRow[x0] = FloatToPixel(right);
                pixelIdx++;

                if (pixelIdx >= usedPixels) break;

                // 奇数像素 → left
                int y1 = (int)(pixelIdx / width);
                int x1 = (int)(pixelIdx % width);
                if (y1 != curY) { curY = y1; curRow = accessor.GetRowSpan(y1); }
                curRow[x1] = FloatToPixel(left);
                pixelIdx++;
            }
        });

        Save(image, opts.OutputPath, opts.Format);
        AnsiConsole.MarkupLine($"[green]已写入: {opts.OutputPath} ({opts.Format})[/]");
@@ -153,19 +181,15 @@ public class AudioImage : IPlayground

    private static (int width, int height) ComputeDimensions(long totalPixels, int? reqWidth, int? reqHeight)
    {
        // 两者都设置:固定尺寸,多余采样丢弃
        if (reqWidth is { } w && reqHeight is { } h)
            return (w, h);

        // 只设置宽度:高度自动 = totalPixels / width
        if (reqWidth is { } w2)
            return (w2, (int)Math.Max(1, totalPixels / w2));

        // 只设置高度:宽度自动 = totalPixels / height
        if (reqHeight is { } h2)
            return ((int)Math.Max(1, totalPixels / h2), h2);

        // 都未设置:尽量正方形
        int side = (int)Math.Ceiling(Math.Sqrt(totalPixels));
        side = Math.Max(1, side);
        int autoHeight = (int)Math.Ceiling((double)totalPixels / side);
@@ -173,32 +197,17 @@ public class AudioImage : IPlayground
    }

    /// <summary>
    /// 从 32 个音频帧还原 1 个 Rgba32 像素。
    /// 奇数像素取左声道,偶数像素取右声道(与 ImageAudio 正向映射对应);
    /// 采样值 > 0 → bit 1,否则 → bit 0。
    /// float → RGBA 4字节(与 ImageAudio.PixelToFloat 互逆)
    /// </summary>
    private static Rgba32 DecodePixel(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 < FramesPerPixel; 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 Rgba32 FloatToPixel(float v)
    {
        int bits = BitConverter.SingleToInt32Bits(v);
        return new Rgba32(
            (byte)(bits & 0xFF),
            (byte)((bits >> 8) & 0xFF),
            (byte)((bits >> 16) & 0xFF),
            (byte)((bits >> 24) & 0xFF)
        );
    }

    private static void Save(Image image, string path, string format)
+54 −51
Original line number Diff line number Diff line
@@ -12,9 +12,8 @@ public class ImageAudio : IPlayground
{
    public string LaunchCmd { get; } = "ia";
    public string? Name { get; } = "Image Audio";
    public string? Description { get; } = "将32位像素转换成音频采样(奇数像素→左声道,偶数像素→右声道,支持WAV/MP3输出、Ctrl+C停止)";
    public string? Description { get; } = "将像素RGBA字节直接作为音频采样(奇数像素→左声道,偶数像素→右声道,可逆转换,支持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
@@ -31,9 +30,13 @@ public class ImageAudio : IPlayground
            return;
        }

        if (!EnsureOutputAvailable(opts.WavPath)) return;
        if (!EnsureOutputAvailable(opts.Mp3Path)) return;

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

@@ -74,6 +77,16 @@ public class ImageAudio : IPlayground
        }
    }

    private static bool EnsureOutputAvailable(string? path)
    {
        if (path is not null && File.Exists(path))
        {
            AnsiConsole.MarkupLine($"[red]输出文件已存在: {path}[/]");
            return false;
        }
        return true;
    }

    private sealed class Options
    {
        public required string ImagePath { get; init; }
@@ -171,8 +184,8 @@ public class ImageAudio : IPlayground
    }

    /// <summary>
    /// 按需生成采样的 ISampleProvider
    /// 数像素 → 声道,数像素 → 声道;每像素 32 个立体声帧。
    /// 可逆映射:像素的 RGBA 4字节直接作为一个 float 采样的4字节
    /// 数像素 → 声道,数像素 → 声道;每2个像素产生1个立体声帧。
    /// 仅缓存一行像素,避免一次性持有全部采样。
    /// </summary>
    private sealed class PixelSampleProvider : ISampleProvider
@@ -182,11 +195,7 @@ public class ImageAudio : IPlayground
        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; }

@@ -199,7 +208,6 @@ public class ImageAudio : IPlayground
            _rowY = 0;
            _x = 0;
            LoadRow(0);
            SetCurrent();
        }

        private void LoadRow(int y)
@@ -207,19 +215,8 @@ public class ImageAudio : IPlayground
            _image.ProcessPixelRows(accessor => accessor.GetRowSpan(y).CopyTo(_rowBuf));
        }

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

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

            _pixelIdx++;
            _x++;
            if (_x >= _image.Width)
@@ -229,55 +226,61 @@ public class ImageAudio : IPlayground
                if (_rowY >= _image.Height) return false;
                LoadRow(_rowY);
            }
            SetCurrent();
            return true;
        }

        public int Read(float[] buffer, int offset, int count)
        {
            int written = 0;
            int limit = count - (Channels - 1); // 保证能写满一个立体声帧
            while (written < limit)
            while (written + 1 < count)
            {
                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;
                }
                // 偶数像素 → 右声道
                Rgba32 evenPixel = _rowBuf[_x];
                float right = PixelToFloat(evenPixel);

                if (_showColor)
                    AnsiConsole.Write(new Text(" ", new Style(null, new Color(evenPixel.R, evenPixel.G, evenPixel.B))));

                float v = GetBitValue(_current, _bitInPixel);
                int idx = offset + written;
                if (_toLeft)
                if (!AdvancePixel())
                {
                    buffer[idx] = v;
                    buffer[idx + 1] = 0f;
                    // 没有奇数像素了,只有右声道
                    buffer[offset + written++] = 0f;
                    buffer[offset + written++] = right;
                    break;
                }
                else

                if (_rowY >= _image.Height)
                {
                    buffer[idx] = 0f;
                    buffer[idx + 1] = v;
                    buffer[offset + written++] = 0f;
                    buffer[offset + written++] = right;
                    break;
                }
                written += Channels;

                if (!Advance()) break;
                // 奇数像素 → 左声道
                Rgba32 oddPixel = _rowBuf[_x];
                float left = PixelToFloat(oddPixel);

                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;

                if (!AdvancePixel()) 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
        /// <summary>
        /// RGBA 4字节 → float(小端序,与 BitConverter.Int32BitsToSingle 对应)
        /// </summary>
        private static float PixelToFloat(Rgba32 px)
        {
                0 => px.R,
                1 => px.G,
                2 => px.B,
                _ => px.A,
            };
            return ((channel >> (bit & 7)) & 1) != 0 ? 1f : -1f;
            int bits = px.R | (px.G << 8) | (px.B << 16) | (px.A << 24);
            return BitConverter.Int32BitsToSingle(bits);
        }
    }
}