Loading TestV/Playgrounds/ImageAudio.cs +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; Loading @@ -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; } } } TestV/TestV.csproj +3 −1 Original line number Diff line number Diff line <Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> Loading @@ -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" /> Loading Loading
TestV/Playgrounds/ImageAudio.cs +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; Loading @@ -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; } } }
TestV/TestV.csproj +3 −1 Original line number Diff line number Diff line <Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> Loading @@ -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" /> Loading