-
Notifications
You must be signed in to change notification settings - Fork 524
Expand file tree
/
Copy pathAudioSink.cs
More file actions
335 lines (297 loc) · 11.6 KB
/
Copy pathAudioSink.cs
File metadata and controls
335 lines (297 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
//-----------------------------------------------------------------------------
// Filename: AudioSink.cs
//
// Description: Routes received, decoded PCM audio to one of three sinks:
// - "play": a spawned ffplay child process rendering to the speakers,
// leaving the verb's stdout untouched.
// - <file.wav>: a WAV file (header patched with the final sizes on close).
// - "-": raw s16le PCM on stdout. The caller is responsible for
// routing its result object to stderr in this mode, per the
// rule that stdout carries exactly one payload.
//
// The sink initialises lazily on the first write because the sample rate is
// only known once the audio format has been negotiated.
//
// Author(s):
// Aaron Clauson (aaron@sipsorcery.com)
//
// History:
// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
//
// License:
// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
//-----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace SIPSorcery.Cli.Commands;
public sealed class AudioSink : IDisposable
{
private enum SinkMode
{
None,
Wav,
Stdout,
Play
}
private readonly SinkMode _mode;
private readonly string? _filePath;
private readonly ILogger _logger;
private readonly object _lock = new();
private Stream? _out;
private FileStream? _wavFile;
private Process? _ffplay;
private bool _failed;
private long _bytesWritten;
public bool IsActive => _mode != SinkMode.None;
public bool IsStdout => _mode == SinkMode.Stdout;
public long BytesWritten { get { lock (_lock) { return _bytesWritten; } } }
private AudioSink(SinkMode mode, string? filePath, ILogger logger)
{
_mode = mode;
_filePath = filePath;
_logger = logger;
}
public static AudioSink Create(string? spec, ILogger logger, out string? error)
{
error = null;
if (string.IsNullOrWhiteSpace(spec))
{
return new AudioSink(SinkMode.None, null, logger);
}
if (spec == "-")
{
return new AudioSink(SinkMode.Stdout, null, logger);
}
if (spec.Equals("play", StringComparison.OrdinalIgnoreCase))
{
return new AudioSink(SinkMode.Play, null, logger);
}
if (spec.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
return new AudioSink(SinkMode.Wav, spec, logger);
}
error = $"--audio must be \"play\", \"-\" or a .wav file path. Got \"{spec}\".";
return new AudioSink(SinkMode.None, null, logger);
}
/// <summary>
/// Writes a block of decoded mono PCM. The first call fixes the sample rate for the sink.
/// </summary>
public void Write(short[] pcm, int sampleRate)
{
if (_mode == SinkMode.None || _failed || pcm.Length == 0)
{
return;
}
lock (_lock)
{
if (_out == null && !Init(sampleRate))
{
return;
}
var bytes = new byte[pcm.Length * sizeof(short)];
Buffer.BlockCopy(pcm, 0, bytes, 0, bytes.Length);
try
{
_out!.Write(bytes, 0, bytes.Length);
_out.Flush();
_bytesWritten += bytes.Length;
}
catch (Exception excp)
{
// e.g. ffplay was closed by the user, or the downstream pipe broke.
_logger.LogWarning("Audio sink write failed, no further audio will be written: {Error}", excp.Message);
_failed = true;
}
}
}
private bool Init(int sampleRate)
{
try
{
switch (_mode)
{
case SinkMode.Wav:
_wavFile = new FileStream(_filePath!, FileMode.Create, FileAccess.ReadWrite);
WavFile.WriteHeader(_wavFile, sampleRate);
_out = _wavFile;
_logger.LogDebug("Writing received audio to {FilePath} at {SampleRate}Hz.", _filePath, sampleRate);
return true;
case SinkMode.Stdout:
_out = Console.OpenStandardOutput();
Console.Error.WriteLine($"Writing raw PCM to stdout: s16le, {sampleRate} Hz, mono.");
return true;
case SinkMode.Play:
var startInfo = new ProcessStartInfo("ffplay")
{
// Note -ch_layout rather than the -ac option which was removed in ffplay 8.
Arguments = $"-hide_banner -loglevel error -nodisp -autoexit -f s16le -ar {sampleRate} -ch_layout mono -i -",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true
};
_ffplay = Process.Start(startInfo);
if (_ffplay == null)
{
throw new ApplicationException("ffplay did not start.");
}
// Drain ffplay's stderr so it cannot block, surfacing anything it says as debug.
_ = Task.Run(async () =>
{
string? line;
while ((line = await _ffplay.StandardError.ReadLineAsync().ConfigureAwait(false)) != null)
{
_logger.LogDebug("ffplay: {Line}", line);
}
});
_out = _ffplay.StandardInput.BaseStream;
Console.Error.WriteLine($"Rendering received audio with ffplay ({sampleRate} Hz mono).");
return true;
default:
return false;
}
}
catch (Exception excp) when (_mode == SinkMode.Play)
{
_logger.LogError("Could not start ffplay: {Error}. Install ffmpeg (which includes ffplay) and ensure it is on the PATH.", excp.Message);
_failed = true;
return false;
}
catch (Exception excp)
{
_logger.LogError("Could not initialise the audio sink: {Error}", excp.Message);
_failed = true;
return false;
}
}
public void Dispose()
{
lock (_lock)
{
try
{
if (_wavFile != null)
{
WavFile.PatchHeader(_wavFile, _bytesWritten);
_wavFile.Dispose();
}
else if (_ffplay != null)
{
// Closing stdin lets ffplay drain its buffer and exit (-autoexit).
_ffplay.StandardInput.Close();
if (!_ffplay.WaitForExit(2000))
{
_ffplay.Kill();
}
_ffplay.Dispose();
}
else
{
_out?.Flush();
}
}
catch (Exception excp)
{
_logger.LogDebug("Audio sink close error: {Error}", excp.Message);
}
}
}
}
/// <summary>
/// Minimal 16 bit mono PCM WAV reading/writing, just enough for the audio verbs.
/// </summary>
public static class WavFile
{
private const int HEADER_LENGTH = 44;
public static void WriteHeader(Stream stream, int sampleRate)
{
using var writer = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true);
writer.Write("RIFF"u8);
writer.Write(0); // RIFF chunk size, patched on close.
writer.Write("WAVE"u8);
writer.Write("fmt "u8);
writer.Write(16); // fmt chunk size.
writer.Write((short)1); // PCM.
writer.Write((short)1); // Mono.
writer.Write(sampleRate);
writer.Write(sampleRate * 2); // Byte rate.
writer.Write((short)2); // Block align.
writer.Write((short)16); // Bits per sample.
writer.Write("data"u8);
writer.Write(0); // Data chunk size, patched on close.
}
public static void PatchHeader(FileStream stream, long dataLength)
{
using var writer = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true);
stream.Seek(4, SeekOrigin.Begin);
writer.Write((int)(dataLength + HEADER_LENGTH - 8));
stream.Seek(40, SeekOrigin.Begin);
writer.Write((int)dataLength);
}
/// <summary>
/// Reads a 16 bit mono PCM WAV file sampled at 8 or 16KHz, the formats the audio source
/// can stream.
/// </summary>
public static bool TryReadPcm(string path, out byte[]? pcm, out int sampleRate, out string? error)
{
pcm = null;
sampleRate = 0;
error = null;
try
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream);
if (reader.ReadBytes(4) is not [0x52, 0x49, 0x46, 0x46]) // "RIFF"
{
error = $"\"{path}\" is not a WAV file (missing RIFF header).";
return false;
}
reader.ReadInt32(); // RIFF chunk size.
if (reader.ReadBytes(4) is not [0x57, 0x41, 0x56, 0x45]) // "WAVE"
{
error = $"\"{path}\" is not a WAV file (missing WAVE marker).";
return false;
}
short channels = 0;
short bitsPerSample = 0;
// Walk the chunks looking for fmt and data.
while (stream.Position + 8 <= stream.Length)
{
string chunkId = new(reader.ReadChars(4));
int chunkSize = reader.ReadInt32();
if (chunkId == "fmt ")
{
short audioFormat = reader.ReadInt16();
channels = reader.ReadInt16();
sampleRate = reader.ReadInt32();
reader.ReadInt32(); // Byte rate.
reader.ReadInt16(); // Block align.
bitsPerSample = reader.ReadInt16();
stream.Seek(chunkSize - 16, SeekOrigin.Current); // Skip any fmt extension.
if (audioFormat != 1 || channels != 1 || bitsPerSample != 16 || (sampleRate != 8000 && sampleRate != 16000))
{
error = $"\"{path}\" must be 16 bit mono PCM at 8000 or 16000 Hz " +
$"(found format {audioFormat}, {channels} channel(s), {bitsPerSample} bit, {sampleRate} Hz). " +
"Convert with: ffmpeg -i in.wav -ar 8000 -ac 1 -c:a pcm_s16le out.wav";
return false;
}
}
else if (chunkId == "data")
{
pcm = reader.ReadBytes(chunkSize);
return true;
}
else
{
stream.Seek(chunkSize, SeekOrigin.Current);
}
}
error = $"\"{path}\" has no data chunk.";
return false;
}
catch (Exception excp)
{
error = $"Could not read \"{path}\": {excp.Message}";
return false;
}
}
}