Skip to content

Commit 24be589

Browse files
tinpotnickNick Knightclaude
authored
feat(player): pauseplay/resumeplay - freeze the prompt in place (#171)
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: Nick Knight <nick@tinpot.co.uk> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0e2d8e9 commit 24be589

10 files changed

Lines changed: 231 additions & 1 deletion

File tree

lib/node.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const channelmap = {
2626
"record": ( chan, msg ) => chan.record( msg.options ),
2727
"playrecord": ( chan, msg ) => chan.playrecord( msg.options ),
2828
"stopplay": ( chan ) => chan.stopplay(),
29+
"pauseplay": ( chan ) => chan.pauseplay(),
30+
"resumeplay": ( chan ) => chan.resumeplay(),
2931
"direction": ( chan, msg ) => chan.direction( msg.options ),
3032
/* "transcribe" carries no channel action of its own - it is consumed by the
3133
node's pre hook (the consumer registers a token->channel binding and taps

lib/server.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,32 @@ class channel {
567567
return true
568568
}
569569

570+
/**
571+
@summary Freeze the currently-playing prompt in place (position, loop counters) without destroying it -
572+
the channel sends silence until resumeplay(). Emits play/"paused". Recorders are untouched. Use it to
573+
pause the prompt the moment the caller speaks, then resume (not restart) if the capture turns out not
574+
to be an answer (e.g. a cough).
575+
@returns { boolean }
576+
*/
577+
pauseplay() {
578+
this._write( {
579+
"channel": "pauseplay"
580+
} )
581+
return true
582+
}
583+
584+
/**
585+
@summary Resume a prompt frozen by pauseplay() from where it left off - resume, not restart.
586+
Emits play/"resumed".
587+
@returns { boolean }
588+
*/
589+
resumeplay() {
590+
this._write( {
591+
"channel": "resumeplay"
592+
} )
593+
return true
594+
}
595+
570596
/**
571597
@summary Enable/disable the sending and receiving of RTP traffic
572598
@param { Object } options

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@babblevoice/projectrtp",
3-
"version": "3.6.0",
3+
"version": "3.7.0",
44
"description": "A scalable Node addon RTP server",
55
"main": "index.js",
66
"directories": {

rust/src/channel/actor.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ pub enum Event {
172172
pub enum PlayState {
173173
Start,
174174
End,
175+
/// The player was frozen in place by `pauseplay()` — it holds its
176+
/// position and emits no frames until resumed.
177+
Paused,
178+
/// The player picked up where it left off after `resumeplay()`.
179+
Resumed,
175180
}
176181

177182
#[derive(Debug, Clone)]
@@ -1051,6 +1056,25 @@ async fn handle_command_local(
10511056
LocalOutcome::Continue
10521057
}
10531058

1059+
Command::PausePlay { pause } => {
1060+
// Freeze/unfreeze the player in place — nothing else is touched:
1061+
// recorders keep running, a pending recorder stays pending (the
1062+
// prompt is not over), and no play/end is emitted.
1063+
if let Some(p) = subs.player.as_mut() {
1064+
if p.set_paused(pause) {
1065+
events.post(Event::Play {
1066+
state: if pause {
1067+
PlayState::Paused
1068+
} else {
1069+
PlayState::Resumed
1070+
},
1071+
reason: None,
1072+
});
1073+
}
1074+
}
1075+
LocalOutcome::Continue
1076+
}
1077+
10541078
Command::Dtmf { digits } => {
10551079
subs.dtmf_send.enqueue(&digits);
10561080
LocalOutcome::Continue

rust/src/channel/commands.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@ pub enum Command {
159159
/// `playrecord({ concurrent: true })`: JS captures the answer while the
160160
/// prompt plays, then fires this to interrupt once the answer is acceptable.
161161
StopPlay,
162+
/// `channel.pauseplay()` / `channel.resumeplay()` — freeze/unfreeze the
163+
/// active player in place (position, loops) without destroying it, leaving
164+
/// any recorder untouched. Fire-and-forget. Lets a controller pause the
165+
/// prompt the moment the caller speaks and *resume* (not restart) it when
166+
/// the capture turns out not to be an answer (e.g. a cough).
167+
PausePlay {
168+
pause: bool,
169+
},
162170
Direction(Direction),
163171
/// Migrate this channel's state + subs into a mix group actor. The actor
164172
/// transitions from Local to Mixed mode; subsequent commands are
@@ -254,4 +262,10 @@ impl Handle {
254262
pub async fn stop_play(&self) {
255263
let _ = self.cmd.send(Command::StopPlay).await;
256264
}
265+
266+
/// Pause (true) or resume (false) the active player in place without
267+
/// destroying it. Fire-and-forget (no ack).
268+
pub async fn pause_play(&self, pause: bool) {
269+
let _ = self.cmd.send(Command::PausePlay { pause }).await;
270+
}
257271
}

rust/src/channel/facade.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ fn event_to_payload(ev: Event) -> EventPayload {
105105
match state {
106106
PlayState::Start => "start",
107107
PlayState::End => "end",
108+
PlayState::Paused => "paused",
109+
PlayState::Resumed => "resumed",
108110
}
109111
.into(),
110112
),
@@ -308,6 +310,25 @@ impl ChannelObject {
308310
true
309311
}
310312

313+
/// Freeze the active player in place (position, loop counters) without
314+
/// destroying it — the channel sends silence until `resumeplay()`. Emits
315+
/// `play`/`paused`. Recorders are untouched. Fire-and-forget.
316+
#[napi]
317+
pub fn pauseplay(&self) -> bool {
318+
let cmd = self.handle.cmd.clone();
319+
let _ = cmd.try_send(super::commands::Command::PausePlay { pause: true });
320+
true
321+
}
322+
323+
/// Resume a player frozen by `pauseplay()` from where it left off —
324+
/// resume, not restart. Emits `play`/`resumed`. Fire-and-forget.
325+
#[napi]
326+
pub fn resumeplay(&self) -> bool {
327+
let cmd = self.handle.cmd.clone();
328+
let _ = cmd.try_send(super::commands::Command::PausePlay { pause: false });
329+
true
330+
}
331+
311332
/// JS calls `channel.direction({ send, recv })` with a single object.
312333
#[napi]
313334
pub fn direction(&self, opts: DirectionOpts) -> bool {

rust/src/channel/mixer.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,11 @@ impl Member {
394394
let mut player_frame: Option<Vec<i16>> = None;
395395
let mut player_just_ended = false;
396396
if let Some(player) = self.subs.player.as_mut() {
397+
// A paused player holds its position and produces nothing until
398+
// resumed — mirrors the Local-mode tick.
399+
if player.is_paused() {
400+
return None;
401+
}
397402
let frame = player.read(160).await;
398403
if !frame.samples.is_empty() {
399404
player_frame = Some(frame.samples);
@@ -1446,6 +1451,22 @@ async fn apply_forwarded(m: &mut Member, cmd: Command) {
14461451
}
14471452
}
14481453
}
1454+
Command::PausePlay { pause } => {
1455+
// Freeze/unfreeze the player in place — mirrors the Local-mode
1456+
// handler: recorders untouched, no play/end emitted.
1457+
if let Some(p) = m.subs.player.as_mut() {
1458+
if p.set_paused(pause) {
1459+
m.events.post(Event::Play {
1460+
state: if pause {
1461+
PlayState::Paused
1462+
} else {
1463+
PlayState::Resumed
1464+
},
1465+
reason: None,
1466+
});
1467+
}
1468+
}
1469+
}
14491470
Command::EnterMix { .. } | Command::LeaveMix { .. } | Command::Close { .. } => {}
14501471
}
14511472
}

rust/src/channel/player.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ pub struct Player {
4040
file_loop: u32,
4141
overall_loop: u32,
4242
finished: bool,
43+
/// Paused players hold their position (file index, loop counts, read
44+
/// offset) and emit no frames until resumed — unlike `StopPlay`, which
45+
/// destroys the player. Lets a controller pause a prompt while it
46+
/// classifies a capture, then resume rather than restart.
47+
paused: bool,
4348
/// Samples remaining before the current file's `stop_ms` boundary. `None`
4449
/// when the current file has no stop trim (read until EOF). Decremented
4550
/// each successful read; when it hits 0 we advance like an EOF.
@@ -67,6 +72,7 @@ impl Player {
6772
file_loop: 0,
6873
overall_loop: 0,
6974
finished: false,
75+
paused: false,
7076
remaining_samples: None,
7177
source_sr: 8000,
7278
}
@@ -78,6 +84,18 @@ impl Player {
7884
pub fn interrupts(&self) -> bool {
7985
self.spec.interrupt
8086
}
87+
pub fn is_paused(&self) -> bool {
88+
self.paused
89+
}
90+
/// Returns true when this call changed the paused state (used to decide
91+
/// whether a paused/resumed event is owed).
92+
pub fn set_paused(&mut self, paused: bool) -> bool {
93+
if self.paused == paused || self.finished {
94+
return false;
95+
}
96+
self.paused = paused;
97+
true
98+
}
8199

82100
/// Read up to `samples_wanted` 16-bit samples, advancing through files
83101
/// and loops as needed. Returns fewer samples only when the soup finishes.
@@ -239,6 +257,41 @@ mod tests {
239257
p
240258
}
241259

260+
#[tokio::test]
261+
async fn pause_holds_position_and_resume_continues() {
262+
// Pause must freeze the player in place — the next read after resume
263+
// continues from exactly where it left off (resume, not restart).
264+
let mut samples = vec![1i16; 400];
265+
samples.extend(vec![2i16; 400]);
266+
let p = make_wav("player_pause.wav", &samples).await;
267+
let spec = SoundSoupSpec {
268+
files: vec![SoundSoupFileSpec {
269+
path: p.clone(),
270+
start_ms: None,
271+
stop_ms: None,
272+
max_loops: None,
273+
}],
274+
overall_loops: None,
275+
interrupt: false,
276+
};
277+
let mut pl = Player::new(spec);
278+
let frame = pl.read(400).await;
279+
assert!(frame.samples.iter().all(|&s| s == 1));
280+
281+
// Toggling reports the change; repeats are no-ops (no duplicate events
282+
// owed). The tick layer stops calling read() while paused.
283+
assert!(pl.set_paused(true));
284+
assert!(!pl.set_paused(true));
285+
assert!(pl.is_paused());
286+
assert!(pl.set_paused(false));
287+
assert!(!pl.set_paused(false));
288+
289+
// Continues from sample 400 — the second half, not the first again.
290+
let frame = pl.read(400).await;
291+
assert!(frame.samples.iter().all(|&s| s == 2));
292+
let _ = std::fs::remove_file(&p);
293+
}
294+
242295
#[tokio::test]
243296
async fn plays_single_file_once() {
244297
let p = make_wav("player_single.wav", &vec![1i16; 800]).await;

rust/src/channel/tick.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,11 @@ async fn tick_player(state: &mut ChannelState, subs: &mut Subsystems) -> Option<
177177
let mut player_frame: Option<Vec<i16>> = None;
178178
let mut player_just_ended = false;
179179
if let Some(player) = subs.player.as_mut() {
180+
// A paused player holds its position and produces nothing — the tick
181+
// sends silence and the player cannot finish until resumed.
182+
if player.is_paused() {
183+
return None;
184+
}
180185
let frame = player.read(FRAME_SAMPLES).await;
181186
if !frame.samples.is_empty() {
182187
player_frame = Some(frame.samples);

test/interface/projectrtpplayrecord.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,70 @@ describe( "playrecord", function() {
456456
expect( both, "default both should contain the prompt" ).to.be.above( 1000 )
457457
} )
458458

459+
it( "pauseplay freezes the prompt and resumeplay continues (not restarts)", async function() {
460+
/* the languagegate cough flow: caller speaks -> pause the prompt for
461+
feedback; capture turns out not to be an answer -> resume from where
462+
it left off. The prompt must complete naturally, delayed by roughly
463+
the paused period, with paused/resumed events in between. */
464+
this.timeout( 10000 )
465+
this.slow( 8000 )
466+
467+
const server = dgram.createSocket( "udp4" )
468+
server.on( "message", function() {} )
469+
server.bind()
470+
await new Promise( resolve => server.on( "listening", resolve ) )
471+
472+
const events = []
473+
let done
474+
const finished = new Promise( r => done = r )
475+
476+
const channel = await prtp.projectrtp.openchannel(
477+
{ "remote": { "address": "127.0.0.1", "port": server.address().port, "codec": 0 } },
478+
function( d ) {
479+
events.push( { ...d, at: Date.now() } )
480+
if( "close" === d.action ) done()
481+
}
482+
)
483+
484+
const begin = Date.now()
485+
/* 3s prompt */
486+
expect( channel.playrecord( {
487+
"soup": { "files": [ { "wav": "/tmp/pr_long_prompt.wav" } ] },
488+
"record": { "file": "/tmp/pr_pause_rec.wav", "startabovepower": 100, "maxduration": 8000 },
489+
"concurrent": true
490+
} ) ).to.be.true
491+
492+
/* keep RTP flowing so the channel ticks */
493+
const delayedjobs = []
494+
for( let i = 0; 350 > i; i++ ) {
495+
delayedjobs.push( sendpk( i, i, channel.local.port, server,
496+
Buffer.alloc( 60000, prtp.projectrtp.codecx.linear162pcmu( 0 ) ) ) )
497+
}
498+
499+
setTimeout( () => channel.pauseplay(), 500 )
500+
setTimeout( () => channel.resumeplay(), 2000 ) /* paused ~1.5s */
501+
502+
await new Promise( resolve => setTimeout( resolve, 6000 ) )
503+
channel.close()
504+
await finished
505+
delayedjobs.forEach( id => clearTimeout( id ) )
506+
await new Promise( resolve => server.close( resolve ) )
507+
508+
const paused = events.find( e => "play" === e.action && "paused" === e.event )
509+
const resumed = events.find( e => "play" === e.action && "resumed" === e.event )
510+
expect( paused, "paused event emitted" ).to.exist
511+
expect( resumed, "resumed event emitted" ).to.exist
512+
513+
const playend = events.find( e => "play" === e.action && "end" === e.event )
514+
expect( playend, "prompt completed naturally after resume" ).to.exist
515+
expect( playend.reason ).to.equal( "completed" )
516+
/* 3s prompt + ~1.5s pause: completion must be pushed well past the
517+
prompt's natural 3s end, proving resume (a restart from the top would
518+
also delay it, but pause held position: total ~4.5s not ~5s+) */
519+
const elapsed = playend.at - begin
520+
expect( elapsed, "delayed by the paused period" ).to.be.within( 4000, 5600 )
521+
} )
522+
459523
it( "playrecord with record finish request", async function() {
460524
this.timeout( 3000 )
461525
this.slow( 2500 )

0 commit comments

Comments
 (0)