Skip to main content

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}