Skip to content

Let IVR play_and_collect finish prompts before starting no-input timeout #18

Description

@Justinabox

Summary

CallSession.play_and_collect(..., interrupt=True) starts the DTMF timeout at the same time as prompt playback. If no digit arrives, it cancels the prompt when that timeout expires and returns "", even if the prompt is still playing.

For real IVR menus, this means a prompt longer than the configured DTMF timeout can be cut off before the caller hears the full menu. Even for shorter prompts, the caller effectively gets timeout - prompt_duration seconds to respond rather than the documented timeout after the prompt.

Evidence

Affected code:

  • callstack/voice/service.py:291-311 starts self.play(audio_path) and immediately awaits collector.collect_one_from_stream(..., timeout=timeout).
  • On no input, it cancels play_task and returns "" without waiting for prompt completion or starting a post-prompt no-input window.
  • tests/test_ivr.py mocks session.play_and_collect and does not exercise the real playback/timeout interaction.

Minimal reproduction on main (1c883c59575676fe6ce28214f27aeded692769b8):

PYTHONPATH=. uv run --no-project --with pyserial-asyncio python - <<'PY'
import asyncio
from callstack.events.bus import EventBus
from callstack.voice.service import CallSession

class FakeAudio:
    def __init__(self):
        self.cancelled = False
    async def play_file(self, path, cancel=None):
        try:
            await asyncio.sleep(1.0)  # stand in for a long menu prompt
        except asyncio.CancelledError:
            self.cancelled = True
            raise

class FakeService:
    def __init__(self):
        self._bus = EventBus()
        self._audio = FakeAudio()

async def main():
    service = FakeService()
    session = CallSession(number='unknown', direction='inbound', service=service)
    started = asyncio.get_running_loop().time()
    result = await session.play_and_collect('long-menu.wav', timeout=0.05, interrupt=True)
    elapsed = asyncio.get_running_loop().time() - started
    print('play_and_collect result:', repr(result))
    print('play_and_collect elapsed_lt_prompt:', elapsed < 0.2)
    print('playback_cancelled:', service._audio.cancelled)

asyncio.run(main())
PY

Actual output:

play_and_collect result: ''
play_and_collect elapsed_lt_prompt: True
playback_cancelled: True

The no-input timeout cancelled the still-playing prompt.

Repository health checks from this scout run:

git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
# 307 passed in 4.24s

Duplicate searches run before filing:

gh issue list --state all --search "play_and_collect cancels prompt timeout IVR"
gh pr list --state all --search "play_and_collect cancels prompt timeout IVR"
# no results

Expected behavior

For interrupt=True:

  • A DTMF digit during playback should interrupt/cancel playback and be returned/continued as today.
  • If no digit arrives during playback, playback should be allowed to finish.
  • After playback finishes, the configured no-input timeout should give the caller a response window.

Actual behavior

The same timeout is used as a deadline from the start of playback. When it expires with no digit, playback is cancelled and the method returns "".

Suggested fix direction

  • Race prompt completion against the first DTMF event.
  • If a digit wins, cancel playback and collect remaining digits from the same event stream.
  • If playback wins, then start the normal collection timeout from that point.
  • Add focused tests with a fake audio pipeline covering:
    • digit during prompt interrupts playback;
    • no digit during prompt does not cancel a long prompt early;
    • input after prompt completion is accepted within the timeout.

Acceptance criteria

  • play_and_collect(interrupt=True) no longer cancels prompt playback solely because the no-input timeout expires while no DTMF was received.
  • IVR menu callers get the configured response timeout after the prompt completes.
  • Existing DTMF buffering/no-event-loss behavior remains covered.

Verification gates

git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions