Commit 8d1d0a11 authored by shrabbit's avatar shrabbit
Browse files

feat(AudioImage): 新增音频反向生成图片的游乐场,支持 PNG/JPG/BMP 及宽高参数,并注册到启动项

parent 4e86662e
Loading
Loading
Loading
Loading
+214 −0
Original line number Diff line number Diff line
using NAudio.Wave;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using Spectre.Console;
using TestV.Tools;

namespace TestV.Playgrounds;

public class AudioImage : IPlayground
{
    public string LaunchCmd { get; } = "ai";
    public string? Name { get; } = "Audio Image";
    public string? Description { get; } = "将音频采样反向变换为图片(默认PNG,可选JPG/BMP,可设置宽度或高度)";

    private const int BitsPerPixel = 32;   // 与 ImageAudio 对应:R/G/B/A 各 8 bit
    private const int FramesPerPixel = BitsPerPixel;

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

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

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

    private sealed class Options
    {
        public required string AudioPath { get; init; }
        public required string OutputPath { get; set; }
        public string Format { get; set; } = "png";
        public int? Width { get; set; }
        public int? Height { get; set; }
    }

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

        for (int i = 1; i < args.Length; i++)
        {
            var a = args[i];
            if (a is "--out" or "-o")
            {
                if (i + 1 >= args.Length) { Errors.NoArgs(1); return null; }
                opts.OutputPath = args[++i];
            }
            else if (a.StartsWith("--out="))
                opts.OutputPath = a["--out=".Length..];
            else if (a.StartsWith("--format="))
                opts.Format = a["--format=".Length..].ToLowerInvariant();
            else if (a is "--format")
            {
                if (i + 1 >= args.Length) { Errors.NoArgs(1); return null; }
                opts.Format = args[++i].ToLowerInvariant();
            }
            else if (a.StartsWith("--width=") && int.TryParse(a["--width=".Length..], out var w) && w > 0)
                opts.Width = w;
            else if (a.StartsWith("--height=") && int.TryParse(a["--height=".Length..], out var h) && h > 0)
                opts.Height = h;
            else
            {
                Errors.NotArrow("参数", "--out", "-o", "--out=", "--format", "--format=", "--width=", "--height=");
                return null;
            }
        }

        // 若未显式指定 --format,按输出扩展名推断
        if (!IsFormatArg(args))
        {
            var ext = Path.GetExtension(opts.OutputPath).TrimStart('.').ToLowerInvariant();
            if (ext is "png" or "jpg" or "jpeg" or "bmp")
                opts.Format = ext;
        }
        return opts;
    }

    private static bool IsFormatArg(string[] args)
    {
        foreach (var a in args)
            if (a is "--format" || a.StartsWith("--format=")) return true;
        return false;
    }

    private void Decode(Options opts)
    {
        using var reader = new AudioFileReader(opts.AudioPath);
        int audioChannels = reader.WaveFormat.Channels;
        if (audioChannels < 1)
        {
            AnsiConsole.MarkupLine("[red]无效的音频声道数。[/]");
            return;
        }

        long totalSamples = reader.Length / sizeof(float); // float 采样数
        long totalFrames = totalSamples / audioChannels;
        long totalPixels = totalFrames / FramesPerPixel;

        if (totalPixels <= 0)
        {
            AnsiConsole.MarkupLine("[red]音频太短,不足以还原出 1 个像素。[/]");
            return;
        }

        var (width, height) = ComputeDimensions(totalPixels, opts.Width, opts.Height);
        long usedPixels = (long)width * height;

        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);
        var rowBuf = new float[width * FramesPerPixel * audioChannels];
        long decodedPixels = 0;

        for (int y = 0; y < height; y++)
        {
            int read = reader.Read(rowBuf, 0, rowBuf.Length);
            if (read < rowBuf.Length)
                Array.Clear(rowBuf, read, rowBuf.Length - read);

            image.ProcessPixelRows(accessor =>
            {
                var row = accessor.GetRowSpan(y);
                for (int x = 0; x < width; x++)
                {
                    long pixelIdx = (long)y * width + x;
                    row[x] = DecodePixel(rowBuf, x * FramesPerPixel, audioChannels, pixelIdx);
                }
            });

            decodedPixels += width;
            if (read < rowBuf.Length) break; // EOF:剩余行保持默认(透明)
        }

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

    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);
        return (side, autoHeight);
    }

    /// <summary>
    /// 从 32 个音频帧还原 1 个 Rgba32 像素。
    /// 奇数像素取左声道,偶数像素取右声道(与 ImageAudio 正向映射对应);
    /// 采样值 > 0 → bit 1,否则 → bit 0。
    /// </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 void Save(Image image, string path, string format)
    {
        IImageEncoder encoder = format switch
        {
            "jpg" or "jpeg" => new JpegEncoder(),
            "bmp" => new BmpEncoder(),
            _ => new PngEncoder(),
        };
        image.Save(path, encoder);
    }
}
+2 −1
Original line number Diff line number Diff line
using System.Diagnostics;
using System.Diagnostics;
using System.Globalization;
using System.Numerics;
using System.Reflection;
@@ -167,6 +167,7 @@ class Program
                .Add<Factorial>()
                .Add<SubProgram>()
                .Add<ImageAudio>()
                .Add<AudioImage>()
                .Add<Exp1>();
            
            play.Startup(args);