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