From 71b44fea53ef1d18022554b3b88e9fe5b75212e7 Mon Sep 17 00:00:00 2001 From: Nick Knight Date: Wed, 12 Aug 2026 16:48:48 +0100 Subject: [PATCH] feat(player): pauseplay/resumeplay - freeze the prompt in place stopplay destroys the player, so a controller that stopped a prompt when the caller made a sound had no way back when the sound turned out to be a cough - it could only restart. PausePlay freezes the active player in place (file index, loop counters, read position); the tick paths skip reading while paused, the channel sends silence, and a paused player cannot finish. ResumePlay picks up exactly where it left off - resume, not restart. Recorders are untouched in both directions and a pending recorder stays pending (the prompt is not over). Emits play/"paused" and play/"resumed" events (additive - existing consumers match "start"/"end" exactly). Works in Local and Mixed modes. stopplay still works on a paused player for the settle path. Companion to the languagegate flow: recording.abovepower -> pauseplay (caller gets instant feedback), empty capture -> resumeplay + re-arm, acceptable answer -> stopplay. Co-Authored-By: Claude Fable 5 --- lib/node.js | 2 + lib/server.js | 26 +++++++++++ package.json | 2 +- rust/src/channel/actor.rs | 24 ++++++++++ rust/src/channel/commands.rs | 14 ++++++ rust/src/channel/facade.rs | 21 +++++++++ rust/src/channel/mixer.rs | 21 +++++++++ rust/src/channel/player.rs | 53 +++++++++++++++++++++ rust/src/channel/tick.rs | 5 ++ test/interface/projectrtpplayrecord.js | 64 ++++++++++++++++++++++++++ 10 files changed, 231 insertions(+), 1 deletion(-) diff --git a/lib/node.js b/lib/node.js index 5249ac4..51a6ad9 100644 --- a/lib/node.js +++ b/lib/node.js @@ -26,6 +26,8 @@ const channelmap = { "record": ( chan, msg ) => chan.record( msg.options ), "playrecord": ( chan, msg ) => chan.playrecord( msg.options ), "stopplay": ( chan ) => chan.stopplay(), + "pauseplay": ( chan ) => chan.pauseplay(), + "resumeplay": ( chan ) => chan.resumeplay(), "direction": ( chan, msg ) => chan.direction( msg.options ), /* "transcribe" carries no channel action of its own - it is consumed by the node's pre hook (the consumer registers a token->channel binding and taps diff --git a/lib/server.js b/lib/server.js index 4145288..ff5c6bd 100644 --- a/lib/server.js +++ b/lib/server.js @@ -567,6 +567,32 @@ class channel { return true } + /** + @summary Freeze the currently-playing prompt in place (position, loop counters) without destroying it - + the channel sends silence until resumeplay(). Emits play/"paused". Recorders are untouched. Use it to + pause the prompt the moment the caller speaks, then resume (not restart) if the capture turns out not + to be an answer (e.g. a cough). + @returns { boolean } + */ + pauseplay() { + this._write( { + "channel": "pauseplay" + } ) + return true + } + + /** + @summary Resume a prompt frozen by pauseplay() from where it left off - resume, not restart. + Emits play/"resumed". + @returns { boolean } + */ + resumeplay() { + this._write( { + "channel": "resumeplay" + } ) + return true + } + /** @summary Enable/disable the sending and receiving of RTP traffic @param { Object } options diff --git a/package.json b/package.json index d78333a..9a1c239 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@babblevoice/projectrtp", - "version": "3.6.0", + "version": "3.7.0", "description": "A scalable Node addon RTP server", "main": "index.js", "directories": { diff --git a/rust/src/channel/actor.rs b/rust/src/channel/actor.rs index 08d9aba..1262f5b 100644 --- a/rust/src/channel/actor.rs +++ b/rust/src/channel/actor.rs @@ -172,6 +172,11 @@ pub enum Event { pub enum PlayState { Start, End, + /// The player was frozen in place by `pauseplay()` — it holds its + /// position and emits no frames until resumed. + Paused, + /// The player picked up where it left off after `resumeplay()`. + Resumed, } #[derive(Debug, Clone)] @@ -1051,6 +1056,25 @@ async fn handle_command_local( LocalOutcome::Continue } + Command::PausePlay { pause } => { + // Freeze/unfreeze the player in place — nothing else is touched: + // recorders keep running, a pending recorder stays pending (the + // prompt is not over), and no play/end is emitted. + if let Some(p) = subs.player.as_mut() { + if p.set_paused(pause) { + events.post(Event::Play { + state: if pause { + PlayState::Paused + } else { + PlayState::Resumed + }, + reason: None, + }); + } + } + LocalOutcome::Continue + } + Command::Dtmf { digits } => { subs.dtmf_send.enqueue(&digits); LocalOutcome::Continue diff --git a/rust/src/channel/commands.rs b/rust/src/channel/commands.rs index 3a8473e..1a4cc52 100644 --- a/rust/src/channel/commands.rs +++ b/rust/src/channel/commands.rs @@ -159,6 +159,14 @@ pub enum Command { /// `playrecord({ concurrent: true })`: JS captures the answer while the /// prompt plays, then fires this to interrupt once the answer is acceptable. StopPlay, + /// `channel.pauseplay()` / `channel.resumeplay()` — freeze/unfreeze the + /// active player in place (position, loops) without destroying it, leaving + /// any recorder untouched. Fire-and-forget. Lets a controller pause the + /// prompt the moment the caller speaks and *resume* (not restart) it when + /// the capture turns out not to be an answer (e.g. a cough). + PausePlay { + pause: bool, + }, Direction(Direction), /// Migrate this channel's state + subs into a mix group actor. The actor /// transitions from Local to Mixed mode; subsequent commands are @@ -254,4 +262,10 @@ impl Handle { pub async fn stop_play(&self) { let _ = self.cmd.send(Command::StopPlay).await; } + + /// Pause (true) or resume (false) the active player in place without + /// destroying it. Fire-and-forget (no ack). + pub async fn pause_play(&self, pause: bool) { + let _ = self.cmd.send(Command::PausePlay { pause }).await; + } } diff --git a/rust/src/channel/facade.rs b/rust/src/channel/facade.rs index 2fa7edb..2e38e8b 100644 --- a/rust/src/channel/facade.rs +++ b/rust/src/channel/facade.rs @@ -105,6 +105,8 @@ fn event_to_payload(ev: Event) -> EventPayload { match state { PlayState::Start => "start", PlayState::End => "end", + PlayState::Paused => "paused", + PlayState::Resumed => "resumed", } .into(), ), @@ -308,6 +310,25 @@ impl ChannelObject { true } + /// Freeze the active player in place (position, loop counters) without + /// destroying it — the channel sends silence until `resumeplay()`. Emits + /// `play`/`paused`. Recorders are untouched. Fire-and-forget. + #[napi] + pub fn pauseplay(&self) -> bool { + let cmd = self.handle.cmd.clone(); + let _ = cmd.try_send(super::commands::Command::PausePlay { pause: true }); + true + } + + /// Resume a player frozen by `pauseplay()` from where it left off — + /// resume, not restart. Emits `play`/`resumed`. Fire-and-forget. + #[napi] + pub fn resumeplay(&self) -> bool { + let cmd = self.handle.cmd.clone(); + let _ = cmd.try_send(super::commands::Command::PausePlay { pause: false }); + true + } + /// JS calls `channel.direction({ send, recv })` with a single object. #[napi] pub fn direction(&self, opts: DirectionOpts) -> bool { diff --git a/rust/src/channel/mixer.rs b/rust/src/channel/mixer.rs index bdd0474..22f227e 100644 --- a/rust/src/channel/mixer.rs +++ b/rust/src/channel/mixer.rs @@ -394,6 +394,11 @@ impl Member { let mut player_frame: Option> = None; let mut player_just_ended = false; if let Some(player) = self.subs.player.as_mut() { + // A paused player holds its position and produces nothing until + // resumed — mirrors the Local-mode tick. + if player.is_paused() { + return None; + } let frame = player.read(160).await; if !frame.samples.is_empty() { player_frame = Some(frame.samples); @@ -1446,6 +1451,22 @@ async fn apply_forwarded(m: &mut Member, cmd: Command) { } } } + Command::PausePlay { pause } => { + // Freeze/unfreeze the player in place — mirrors the Local-mode + // handler: recorders untouched, no play/end emitted. + if let Some(p) = m.subs.player.as_mut() { + if p.set_paused(pause) { + m.events.post(Event::Play { + state: if pause { + PlayState::Paused + } else { + PlayState::Resumed + }, + reason: None, + }); + } + } + } Command::EnterMix { .. } | Command::LeaveMix { .. } | Command::Close { .. } => {} } } diff --git a/rust/src/channel/player.rs b/rust/src/channel/player.rs index 15c8298..765559d 100644 --- a/rust/src/channel/player.rs +++ b/rust/src/channel/player.rs @@ -40,6 +40,11 @@ pub struct Player { file_loop: u32, overall_loop: u32, finished: bool, + /// Paused players hold their position (file index, loop counts, read + /// offset) and emit no frames until resumed — unlike `StopPlay`, which + /// destroys the player. Lets a controller pause a prompt while it + /// classifies a capture, then resume rather than restart. + paused: bool, /// Samples remaining before the current file's `stop_ms` boundary. `None` /// when the current file has no stop trim (read until EOF). Decremented /// each successful read; when it hits 0 we advance like an EOF. @@ -67,6 +72,7 @@ impl Player { file_loop: 0, overall_loop: 0, finished: false, + paused: false, remaining_samples: None, source_sr: 8000, } @@ -78,6 +84,18 @@ impl Player { pub fn interrupts(&self) -> bool { self.spec.interrupt } + pub fn is_paused(&self) -> bool { + self.paused + } + /// Returns true when this call changed the paused state (used to decide + /// whether a paused/resumed event is owed). + pub fn set_paused(&mut self, paused: bool) -> bool { + if self.paused == paused || self.finished { + return false; + } + self.paused = paused; + true + } /// Read up to `samples_wanted` 16-bit samples, advancing through files /// and loops as needed. Returns fewer samples only when the soup finishes. @@ -239,6 +257,41 @@ mod tests { p } + #[tokio::test] + async fn pause_holds_position_and_resume_continues() { + // Pause must freeze the player in place — the next read after resume + // continues from exactly where it left off (resume, not restart). + let mut samples = vec![1i16; 400]; + samples.extend(vec![2i16; 400]); + let p = make_wav("player_pause.wav", &samples).await; + let spec = SoundSoupSpec { + files: vec![SoundSoupFileSpec { + path: p.clone(), + start_ms: None, + stop_ms: None, + max_loops: None, + }], + overall_loops: None, + interrupt: false, + }; + let mut pl = Player::new(spec); + let frame = pl.read(400).await; + assert!(frame.samples.iter().all(|&s| s == 1)); + + // Toggling reports the change; repeats are no-ops (no duplicate events + // owed). The tick layer stops calling read() while paused. + assert!(pl.set_paused(true)); + assert!(!pl.set_paused(true)); + assert!(pl.is_paused()); + assert!(pl.set_paused(false)); + assert!(!pl.set_paused(false)); + + // Continues from sample 400 — the second half, not the first again. + let frame = pl.read(400).await; + assert!(frame.samples.iter().all(|&s| s == 2)); + let _ = std::fs::remove_file(&p); + } + #[tokio::test] async fn plays_single_file_once() { let p = make_wav("player_single.wav", &vec![1i16; 800]).await; diff --git a/rust/src/channel/tick.rs b/rust/src/channel/tick.rs index 2a0f0a6..439e910 100644 --- a/rust/src/channel/tick.rs +++ b/rust/src/channel/tick.rs @@ -177,6 +177,11 @@ async fn tick_player(state: &mut ChannelState, subs: &mut Subsystems) -> Option< let mut player_frame: Option> = None; let mut player_just_ended = false; if let Some(player) = subs.player.as_mut() { + // A paused player holds its position and produces nothing — the tick + // sends silence and the player cannot finish until resumed. + if player.is_paused() { + return None; + } let frame = player.read(FRAME_SAMPLES).await; if !frame.samples.is_empty() { player_frame = Some(frame.samples); diff --git a/test/interface/projectrtpplayrecord.js b/test/interface/projectrtpplayrecord.js index a45cde3..4d03b7c 100644 --- a/test/interface/projectrtpplayrecord.js +++ b/test/interface/projectrtpplayrecord.js @@ -456,6 +456,70 @@ describe( "playrecord", function() { expect( both, "default both should contain the prompt" ).to.be.above( 1000 ) } ) + it( "pauseplay freezes the prompt and resumeplay continues (not restarts)", async function() { + /* the languagegate cough flow: caller speaks -> pause the prompt for + feedback; capture turns out not to be an answer -> resume from where + it left off. The prompt must complete naturally, delayed by roughly + the paused period, with paused/resumed events in between. */ + this.timeout( 10000 ) + this.slow( 8000 ) + + const server = dgram.createSocket( "udp4" ) + server.on( "message", function() {} ) + server.bind() + await new Promise( resolve => server.on( "listening", resolve ) ) + + const events = [] + let done + const finished = new Promise( r => done = r ) + + const channel = await prtp.projectrtp.openchannel( + { "remote": { "address": "127.0.0.1", "port": server.address().port, "codec": 0 } }, + function( d ) { + events.push( { ...d, at: Date.now() } ) + if( "close" === d.action ) done() + } + ) + + const begin = Date.now() + /* 3s prompt */ + expect( channel.playrecord( { + "soup": { "files": [ { "wav": "/tmp/pr_long_prompt.wav" } ] }, + "record": { "file": "/tmp/pr_pause_rec.wav", "startabovepower": 100, "maxduration": 8000 }, + "concurrent": true + } ) ).to.be.true + + /* keep RTP flowing so the channel ticks */ + const delayedjobs = [] + for( let i = 0; 350 > i; i++ ) { + delayedjobs.push( sendpk( i, i, channel.local.port, server, + Buffer.alloc( 60000, prtp.projectrtp.codecx.linear162pcmu( 0 ) ) ) ) + } + + setTimeout( () => channel.pauseplay(), 500 ) + setTimeout( () => channel.resumeplay(), 2000 ) /* paused ~1.5s */ + + await new Promise( resolve => setTimeout( resolve, 6000 ) ) + channel.close() + await finished + delayedjobs.forEach( id => clearTimeout( id ) ) + await new Promise( resolve => server.close( resolve ) ) + + const paused = events.find( e => "play" === e.action && "paused" === e.event ) + const resumed = events.find( e => "play" === e.action && "resumed" === e.event ) + expect( paused, "paused event emitted" ).to.exist + expect( resumed, "resumed event emitted" ).to.exist + + const playend = events.find( e => "play" === e.action && "end" === e.event ) + expect( playend, "prompt completed naturally after resume" ).to.exist + expect( playend.reason ).to.equal( "completed" ) + /* 3s prompt + ~1.5s pause: completion must be pushed well past the + prompt's natural 3s end, proving resume (a restart from the top would + also delay it, but pause held position: total ~4.5s not ~5s+) */ + const elapsed = playend.at - begin + expect( elapsed, "delayed by the paused period" ).to.be.within( 4000, 5600 ) + } ) + it( "playrecord with record finish request", async function() { this.timeout( 3000 ) this.slow( 2500 )