Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
24 changes: 24 additions & 0 deletions rust/src/channel/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions rust/src/channel/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
21 changes: 21 additions & 0 deletions rust/src/channel/facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions rust/src/channel/mixer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,11 @@ impl Member {
let mut player_frame: Option<Vec<i16>> = 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);
Expand Down Expand Up @@ -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 { .. } => {}
}
}
53 changes: 53 additions & 0 deletions rust/src/channel/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -67,6 +72,7 @@ impl Player {
file_loop: 0,
overall_loop: 0,
finished: false,
paused: false,
remaining_samples: None,
source_sr: 8000,
}
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions rust/src/channel/tick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ async fn tick_player(state: &mut ChannelState, subs: &mut Subsystems) -> Option<
let mut player_frame: Option<Vec<i16>> = 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);
Expand Down
64 changes: 64 additions & 0 deletions test/interface/projectrtpplayrecord.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 )
Expand Down