Loading TestV/Playgrounds/AudioImage.cs +123 −13 Original line number Diff line number Diff line Loading @@ -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) { Loading Loading @@ -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) Loading Loading @@ -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; } } Loading @@ -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) { Loading @@ -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; Loading @@ -141,7 +197,6 @@ public class AudioImage : IPlayground while (pixelIdx < usedPixels) { // 确保缓冲区有至少2个采样(1帧) if (bufPos + 2 > bufLen) { int remaining = bufLen - bufPos; Loading @@ -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; Loading @@ -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) Loading @@ -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) { Loading TestV/Playgrounds/ImageAudio.cs +166 −45 Original line number Diff line number Diff line Loading @@ -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) { Loading @@ -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)[/]"); } Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -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; Loading @@ -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)]; Loading @@ -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(); Loading @@ -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++; Loading @@ -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) Loading @@ -245,7 +288,6 @@ public class ImageAudio : IPlayground if (!AdvancePixel()) { // 没有奇数像素了,只有右声道 buffer[offset + written++] = 0f; buffer[offset + written++] = right; break; Loading @@ -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; Loading @@ -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; } } } Loading
TestV/Playgrounds/AudioImage.cs +123 −13 Original line number Diff line number Diff line Loading @@ -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) { Loading Loading @@ -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) Loading Loading @@ -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; } } Loading @@ -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) { Loading @@ -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; Loading @@ -141,7 +197,6 @@ public class AudioImage : IPlayground while (pixelIdx < usedPixels) { // 确保缓冲区有至少2个采样(1帧) if (bufPos + 2 > bufLen) { int remaining = bufLen - bufPos; Loading @@ -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; Loading @@ -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) Loading @@ -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) { Loading
TestV/Playgrounds/ImageAudio.cs +166 −45 Original line number Diff line number Diff line Loading @@ -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) { Loading @@ -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)[/]"); } Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -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; Loading @@ -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)]; Loading @@ -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(); Loading @@ -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++; Loading @@ -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) Loading @@ -245,7 +288,6 @@ public class ImageAudio : IPlayground if (!AdvancePixel()) { // 没有奇数像素了,只有右声道 buffer[offset + written++] = 0f; buffer[offset + written++] = right; break; Loading @@ -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; Loading @@ -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; } } }