kobo_core/audio/player.rs
1//! `Player` - the streaming Read Aloud audio spine (review #1 + #3).
2//!
3//! - **Bounded / per-utterance (#3):** the caller splits text into utterances
4//! and calls [`Player::play_utterance`] for each. Each utterance's MP3 is
5//! decoded/resampled/written in isolation (bounded RAM, not whole-chapter),
6//! and `write_all` blocks on the A2DP socket's full buffer -> real-time pacing.
7//! - **Drain before STOP (#1):** [`Player::drain_and_stop`] waits for the sink's
8//! buffered audio to finish playing before issuing STOP, so a chapter's last
9//! sentence isn't truncated (the spike's bug).
10//!
11//! Frame-level streaming within an utterance is a further optimization; this is
12//! the streaming v0 that fixes both #1 and #3.
13use super::a2dp::{A2dpError, AospA2dpSink, TARGET_RATE};
14use super::edge_tts::TtsEvent;
15use super::pipeline;
16use std::time::{Duration, Instant};
17use thiserror::Error;
18
19const EDGE_TICKS_PER_SECOND: f64 = 10_000_000.0;
20const BYTES_PER_STEREO_FRAME: u64 = 4;
21
22/// Safety pad added to the drain wait to cover A2DP startup + headset-buffer
23/// latency (>=100-300 ms) that the wall-clock estimate ignores (review #1-residual).
24/// Until the sink exposes a TX-queue-empty poll (SIOCOUTQ), this prevents the
25/// BT-pipeline tail from clipping on longer/slower content.
26const DRAIN_SAFETY_PAD: f64 = 0.5;
27
28/// One word-boundary mark on the playback timeline (review: highlight data).
29/// `time_s` is the absolute playback time (seconds from the first sample) at
30/// which this word should be highlighted; `text` is the word.
31#[derive(Debug, Clone)]
32pub struct WordMark {
33 pub time_s: f64,
34 pub text: String,
35}
36
37/// A decoded, resampled, stereo-ized utterance ready to write to the sink - the
38/// pure-CPU output of [`Player::prepare`] (no I/O). The paced-write driver owns
39/// one of these and feeds it to the sink in small chunks, so a Pause aborts the
40/// write with only a tiny lead buffered (~[`LEAD_FRAMES`] worth), instead of the
41/// whole-utterance burst that `play_utterance` issues.
42#[derive(Debug, Clone, Default)]
43pub struct Prepared {
44 /// Interleaved stereo S16LE @ [`TARGET_RATE`] (2 i16 per frame).
45 pub stereo: Vec<i16>,
46 /// Word boundaries in 100-ns ticks from the utterance start (for 3b highlight).
47 pub bounds: Vec<(u64, String)>,
48}
49
50/// Chunk size for paced writes: ~50 ms of stereo frames @ [`TARGET_RATE`].
51/// (50 ms x 44 100 = 2205 frames.) Tunable on device.
52pub const CHUNK_FRAMES: usize = 2_205;
53/// Write-ahead lead to keep in the sink: ~200 ms of stereo frames. The paced
54/// loop stops feeding once `frames_written - playback >= LEAD_FRAMES`, so a Pause
55/// leaves at most this much buffered -> pause latency ~ 200 ms (vs the 5-10 s
56/// burst buffer). Start here; raise if the device underruns (garble), lower if
57/// pause feels sluggish.
58pub const LEAD_FRAMES: u64 = 8_820;
59/// Silence lead-in written right after START: ~500 ms. The A2DP HAL clips/
60/// distorts the first audio after START (codec + link ramp-up), so we feed
61/// silence first - the artifact consumes silence, not the first spoken word.
62/// NOTE: on-device capture confirmed our sent data is clean (pure silence +
63/// clean speech at ~657 ms); a residual startup noise remains that is
64/// downstream of our pipeline (HAL/headset amp ramp) - not fixable from here
65/// beyond this lead-in. Tunable.
66pub const LEAD_IN_FRAMES: usize = TARGET_RATE / 2;
67
68/// Inter-sentence silence baked into each utterance's PCM (~400 ms).
69pub const SENTENCE_GAP_FRAMES: usize = TARGET_RATE / 1000 * 400;
70
71/// Extra silence after a paragraph-ending sentence (~700 ms total).
72pub const PARA_GAP_FRAMES: usize = TARGET_RATE / 1000 * 700;
73
74#[derive(Debug, Error)]
75pub enum PlayerError {
76 #[error("a2dp: {0}")]
77 A2dp(#[from] A2dpError),
78 #[error("pipeline: {0}")]
79 Pipeline(String),
80}
81
82pub struct Player {
83 sink: AospA2dpSink,
84 started_at: Option<Instant>,
85 frames_written: u64,
86 clock_base: u64,
87 clock_anchor: Option<Instant>,
88 paused_since: Option<Instant>,
89 chunk_buf: Vec<u8>,
90 silence_buf: Vec<u8>,
91}
92
93impl Player {
94 /// Open the control socket + START the A2DP stream.
95 pub async fn open() -> Result<Self, PlayerError> {
96 let mut sink = AospA2dpSink::open().await?;
97 sink.start().await?;
98 Ok(Player {
99 sink,
100 started_at: None,
101 frames_written: 0,
102 clock_base: 0,
103 clock_anchor: None,
104 paused_since: None,
105 chunk_buf: vec![0u8; CHUNK_FRAMES * BYTES_PER_STEREO_FRAME as usize],
106 silence_buf: vec![0u8; CHUNK_FRAMES * BYTES_PER_STEREO_FRAME as usize],
107 })
108 }
109
110 /// Pure-CPU prep for the paced-write path: collect MP3 + word bounds, then
111 /// decode -> resample 24k->44.1k -> mono->stereo. No I/O - the driver controls
112 /// the actual writes (so it can poll for Pause/Stop between chunks).
113 pub fn prepare(events: &[TtsEvent]) -> Result<Prepared, PlayerError> {
114 let mut mp3: Vec<u8> = Vec::new();
115 let mut bounds: Vec<(u64, String)> = Vec::new();
116 for ev in events {
117 match ev {
118 TtsEvent::Audio(b) => mp3.extend_from_slice(b),
119 TtsEvent::WordBoundary { offset, text, .. } => bounds.push((*offset, text.clone())),
120 TtsEvent::TurnEnd => {}
121 }
122 }
123 if mp3.is_empty() {
124 return Ok(Prepared {
125 stereo: Vec::new(),
126 bounds,
127 });
128 }
129 let mono = pipeline::decode_mp3(&mp3).map_err(PlayerError::Pipeline)?;
130 let resampled = pipeline::resample_mono(&mono).map_err(PlayerError::Pipeline)?;
131 let stereo = pipeline::mono_to_stereo(&resampled);
132 Ok(Prepared { stereo, bounds })
133 }
134
135 /// Playback-clock start (absolute seconds since the first sample) for the
136 /// NEXT utterance's [`WordMark`]s - = frames already queued / rate. Capture
137 /// this before writing an utterance, then pass to [`Player::marks`].
138 pub fn next_utt_start_s(&self) -> f64 {
139 self.frames_written as f64 / TARGET_RATE as f64
140 }
141
142 /// Map a prepared utterance's word bounds to absolute-playback-time
143 /// [`WordMark`]s, given the utterance's start (from [`Player::next_utt_start_s`]).
144 pub fn marks(prepared: &Prepared, utt_start_s: f64) -> Vec<WordMark> {
145 prepared
146 .bounds
147 .iter()
148 .map(|(ticks, text)| WordMark {
149 time_s: utt_start_s + *ticks as f64 / EDGE_TICKS_PER_SECOND,
150 text: text.clone(),
151 })
152 .collect()
153 }
154
155 /// Write one chunk of interleaved stereo S16LE to the sink and advance
156 /// `frames_written`. Blocks on the socket's (now small) buffer. The driver
157 /// calls this only when the lead is below [`LEAD_FRAMES`].
158 pub async fn write_chunk(&mut self, stereo: &[i16]) -> Result<(), PlayerError> {
159 if self.started_at.is_none() {
160 self.started_at = Some(Instant::now());
161 }
162 let n = stereo.len() * 2;
163 self.chunk_buf.resize(n, 0);
164 for (i, s) in stereo.iter().enumerate() {
165 self.chunk_buf[i * 2] = (*s & 0xFF) as u8;
166 self.chunk_buf[i * 2 + 1] = (*s >> 8) as u8;
167 }
168 self.sink.write_pcm(&self.chunk_buf[..n]).await?;
169 self.frames_written += (stereo.len() / 2) as u64;
170 Ok(())
171 }
172
173 /// Trickle one chunk (~50 ms) of silence to the A2DP data socket while idle
174 /// (paused/stopped), to keep the link warm so the paired headset doesn't
175 /// drop it - the idle drop is what wedges `btservice` into `ctrl-ack-timeout`
176 /// (findings A5). Transparent to the pacing/resume model: does NOT touch
177 /// `frames_written` or the playback clock. Data-socket only -> no START/STOP,
178 /// so no churn fatigue (unlike A4).
179 pub async fn keepalive(&mut self) -> Result<(), PlayerError> {
180 self.silence_buf.fill(0);
181 self.sink.write_pcm(&self.silence_buf).await?;
182 Ok(())
183 }
184
185 /// Frames the sink has played per the pausable clock (frozen while paused).
186 pub fn playback_frames(&self) -> u64 {
187 let span = match self.clock_anchor {
188 Some(a) => (TARGET_RATE as f64 * a.elapsed().as_secs_f64()) as u64,
189 None => 0,
190 };
191 self.clock_base + span
192 }
193
194 /// Stereo frames written but (per the clock) not yet played - the write-ahead
195 /// lead the paced loop caps at [`LEAD_FRAMES`].
196 pub fn lead_frames(&self) -> u64 {
197 self.frames_written.saturating_sub(self.playback_frames())
198 }
199
200 /// ACTUAL stereo frames buffered in the kernel socket (via TIOCOUTQ).
201 /// Unlike lead_frames() (wall-clock estimate), this is a direct measurement
202 /// - no drift. Use for pacing to avoid both underrun (fot-fot) and
203 /// unbounded buffer (E8: pause latency + resume overlap).
204 pub fn socket_buffered_frames(&self) -> u64 {
205 self.sink.unsent_bytes() as u64 / 4 // 2 bytes/sample x 2 channels
206 }
207
208 /// Total stereo frames pushed into the sink since open (2 i16 per frame).
209 /// Used for telemetry + (3b) absolute WordMark timing.
210 pub fn frames_written(&self) -> u64 {
211 self.frames_written
212 }
213
214 /// Start (or resume) the playback clock. Idempotent - only sets the anchor if
215 /// the clock is currently frozen. Call when (re)entering playback so the lead
216 /// can drain even before the next write.
217 pub fn resume_clock(&mut self) {
218 if self.clock_anchor.is_none() {
219 self.clock_anchor = Some(Instant::now());
220 }
221 self.paused_since = None;
222 }
223
224 pub fn pause_clock(&mut self) {
225 self.clock_base = self.frames_written;
226 self.clock_anchor = None;
227 self.paused_since = Some(Instant::now());
228 }
229
230 pub fn paused_since(&self) -> Option<Duration> {
231 self.paused_since.map(|t| t.elapsed())
232 }
233
234 /// Play one utterance's events (decode -> resample 24k->44.1k -> mono->stereo ->
235 /// write to the sink, paced by backpressure). Bounded to one utterance.
236 ///
237 /// Returns the utterance's [`WordMark`]s tagged with **absolute playback
238 /// time** (review: surface WordBoundary - the data highlight depends on),
239 /// mapped as: `time_s = utterance_playback_start + edge_offset_seconds`,
240 /// where the edge offset is in 100-ns ticks (resampling preserves time).
241 pub async fn play_utterance(
242 &mut self,
243 events: &[TtsEvent],
244 ) -> Result<Vec<WordMark>, PlayerError> {
245 let prepared = Self::prepare(events)?;
246 if prepared.stereo.is_empty() {
247 return Ok(Vec::new());
248 }
249 if self.started_at.is_none() {
250 self.started_at = Some(Instant::now());
251 }
252 let utt_start_s = self.frames_written as f64 / TARGET_RATE as f64;
253 let bytes: Vec<u8> = prepared
254 .stereo
255 .iter()
256 .flat_map(|s| s.to_le_bytes())
257 .collect();
258 self.sink.write_pcm(&bytes).await?;
259 self.frames_written += (prepared.stereo.len() / 2) as u64;
260 Ok(Self::marks(&prepared, utt_start_s))
261 }
262
263 /// Buffered audio still in the sink (stereo frames not yet played).
264 fn buffered_frames(&self) -> u64 {
265 self.sink.unsent_bytes() as u64 / BYTES_PER_STEREO_FRAME
266 }
267
268 /// Wait for the sink to finish playing buffered audio (+ a safety pad for
269 /// BT pipeline latency), then STOP (#1).
270 pub async fn drain_and_stop(mut self) -> Result<(), PlayerError> {
271 let drain_secs = self.buffered_frames() as f64 / TARGET_RATE as f64 + DRAIN_SAFETY_PAD;
272 if drain_secs > 0.0 {
273 tokio::time::sleep(Duration::from_secs_f64(drain_secs)).await;
274 }
275 self.sink.stop().await?;
276 Ok(())
277 }
278}