aede_core/playback/
output.rs

1//! A local PCM output session that can span several decoded files.
2//!
3//! The session owns the output process, not the queue or decoder. A caller may
4//! keep it across tracks in a local player. Raw `f32le` input has one
5//! fixed rate and channel count per process; a format change drains the old
6//! process before opening another one. Resampling is a separate concern.
7
8use std::ffi::OsString;
9use std::fmt;
10use std::io::{self, Write};
11use std::path::PathBuf;
12use std::process::{Child, ChildStdin, Command, Stdio};
13
14pub use super::format::PcmStreamFormat as OutputFormat;
15
16/// What happened when a track's PCM format reached the output.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum OutputChange {
19    /// An empty session opened its first process.
20    Opened,
21    /// The current process accepts this format and remains open.
22    Reused,
23    /// A different rate or channel count required a new process.
24    ReopenedForFormat,
25}
26
27/// Failure to describe, start, or use the local output.
28#[derive(Debug)]
29pub enum OutputError {
30    /// A raw PCM stream must name its speaker positions explicitly.
31    InvalidLayout,
32    /// The output process could not start.
33    Start(io::Error),
34    /// The started process did not provide a writable PCM input.
35    MissingInput,
36    /// Writing, stopping, or waiting for the process failed.
37    Device(io::Error),
38    /// The output process reported an unsuccessful exit.
39    Exit(std::process::ExitStatus),
40}
41
42impl fmt::Display for OutputError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::InvalidLayout => f.write_str("audio output needs a known channel layout"),
46            Self::Start(error) => write!(
47                f,
48                "cannot start ffplay: {error}; install ffplay for local audio output"
49            ),
50            Self::MissingInput => f.write_str("ffplay did not open its PCM input"),
51            Self::Device(error) => write!(f, "audio output failed: {error}"),
52            Self::Exit(status) => write!(
53                f,
54                "ffplay stopped with {status}; check the audio output device"
55            ),
56        }
57    }
58}
59
60impl std::error::Error for OutputError {}
61
62/// One output process for each uninterrupted run of a PCM format.
63///
64/// A natural track transition reuses the process. The caller should call
65/// `abort` when seeking or skipping, because ffplay may have queued samples
66/// from the old track. `finish` closes its input and waits for the last audio
67/// to drain. Dropping an active session stops its child process.
68pub struct OutputSession {
69    program: PathBuf,
70    process: Option<Child>,
71    input: Option<ChildStdin>,
72    format: Option<OutputFormat>,
73}
74
75impl Default for OutputSession {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl OutputSession {
82    /// Use `ffplay` from the local executable search path.
83    pub fn new() -> Self {
84        Self::with_program(PathBuf::from("ffplay"))
85    }
86
87    fn with_program(program: PathBuf) -> Self {
88        Self {
89            program,
90            process: None,
91            input: None,
92            format: None,
93        }
94    }
95
96    /// The format currently accepted by the process, if any.
97    pub fn format(&self) -> Option<OutputFormat> {
98        self.format
99    }
100
101    /// Process identifier used by the terminal transport controls.
102    pub fn process_id(&self) -> Option<u32> {
103        self.process.as_ref().map(Child::id)
104    }
105
106    /// Reuse matching output or drain and reopen it for a new format.
107    pub fn prepare(&mut self, format: OutputFormat) -> Result<OutputChange, OutputError> {
108        let layout = format
109            .layout()
110            .ffmpeg_name()
111            .ok_or(OutputError::InvalidLayout)?;
112        if self.format == Some(format) && self.input.is_some() && !self.poll_finished()? {
113            return Ok(OutputChange::Reused);
114        }
115        let changed = self.format.is_some_and(|held| held != format);
116        self.finish()?;
117        let mut command = Command::new(&self.program);
118        command
119            .args([
120                OsString::from("-hide_banner"),
121                OsString::from("-loglevel"),
122                OsString::from("error"),
123                OsString::from("-nodisp"),
124                OsString::from("-autoexit"),
125                OsString::from("-nostats"),
126                OsString::from("-f"),
127                OsString::from("f32le"),
128                OsString::from("-sample_rate"),
129                OsString::from(format.sample_rate().to_string()),
130                OsString::from("-ch_layout"),
131                OsString::from(layout),
132                OsString::from("-i"),
133                OsString::from("pipe:0"),
134            ])
135            .stdin(Stdio::piped())
136            .stdout(Stdio::null());
137        let mut process = command.spawn().map_err(OutputError::Start)?;
138        let Some(input) = process.stdin.take() else {
139            let _ = process.kill();
140            let _ = process.wait();
141            return Err(OutputError::MissingInput);
142        };
143        self.process = Some(process);
144        self.input = Some(input);
145        self.format = Some(format);
146        Ok(if changed {
147            OutputChange::ReopenedForFormat
148        } else {
149            OutputChange::Opened
150        })
151    }
152
153    /// Close the input while allowing the process to play queued samples.
154    pub fn close_input(&mut self) {
155        drop(self.input.take());
156    }
157
158    /// Return true after a successful child exit, or an error for failure.
159    pub fn poll_finished(&mut self) -> Result<bool, OutputError> {
160        let Some(process) = self.process.as_mut() else {
161            return Ok(true);
162        };
163        let Some(status) = process.try_wait().map_err(OutputError::Device)? else {
164            return Ok(false);
165        };
166        drop(self.input.take());
167        drop(self.process.take());
168        self.format = None;
169        if !status.success() {
170            return Err(OutputError::Exit(status));
171        }
172        Ok(true)
173    }
174
175    /// Close the input and wait for the output process to drain normally.
176    pub fn finish(&mut self) -> Result<(), OutputError> {
177        self.close_input();
178        let Some(mut process) = self.process.take() else {
179            self.format = None;
180            return Ok(());
181        };
182        let status = process.wait().map_err(OutputError::Device)?;
183        self.format = None;
184        if !status.success() {
185            return Err(OutputError::Exit(status));
186        }
187        Ok(())
188    }
189
190    /// Discard queued output and stop the process after a transport change.
191    pub fn abort(&mut self) -> Result<(), OutputError> {
192        self.close_input();
193        let Some(mut process) = self.process.take() else {
194            self.format = None;
195            return Ok(());
196        };
197        let result = process.kill();
198        let waited = process.wait();
199        self.format = None;
200        if let Err(error) = result
201            && error.kind() != io::ErrorKind::InvalidInput
202        {
203            return Err(OutputError::Device(error));
204        }
205        waited.map_err(OutputError::Device)?;
206        Ok(())
207    }
208}
209
210impl Write for OutputSession {
211    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
212        self.input
213            .as_mut()
214            .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "audio output is closed"))?
215            .write(bytes)
216    }
217
218    fn flush(&mut self) -> io::Result<()> {
219        self.input
220            .as_mut()
221            .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "audio output is closed"))?
222            .flush()
223    }
224}
225
226impl Drop for OutputSession {
227    fn drop(&mut self) {
228        let _ = self.abort();
229    }
230}
231
232#[cfg(test)]
233#[path = "output_tests.rs"]
234mod tests;