Skip to content

Fail closed when recording starts without an active audio pipeline #108

Description

@Justinabox

Summary

AudioPipeline.record() silently creates and returns a valid-looking but empty WAV file when recording starts while the audio pipeline is not running. CallSession.record() delegates directly to this method, so a PBX/voicemail flow can report success even though no audio transport was active and no caller audio could have been captured.

This is a robustness/DX gap for the voice and voicemail roadmap: a missing audio bridge, failed CPCMREG, or disconnected audio serial port should fail closed with an actionable error, not produce a 44-byte empty WAV that looks like a saved recording.

Evidence

Baseline on main is healthy:

$ git diff --check
# exit 0

$ PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
537 passed in 5.67s

Minimal no-hardware repro:

$ PYTHONPATH=. uv run --no-project --with pyserial-asyncio python - <<'PY'
import asyncio, os, tempfile, wave
from callstack.events.bus import EventBus
from callstack.transport.mock import MockTransport
from callstack.voice.audio import AudioPipeline

async def main():
    out = os.path.join(tempfile.gettempdir(), 'callstack-record-not-running.wav')
    try:
        os.remove(out)
    except FileNotFoundError:
        pass
    pipeline = AudioPipeline(MockTransport(), EventBus())
    result = await pipeline.record(out, max_duration=0.2)
    with wave.open(result, 'rb') as wf:
        print('record_returned:', result)
        print('pipeline_running:', pipeline.running)
        print('frames:', wf.getnframes())
        print('channels:', wf.getnchannels())
        print('rate:', wf.getframerate())
        print('file_bytes:', os.path.getsize(result))

asyncio.run(main())
PY
record_returned: /var/folders/n7/73kdk745271b818g855__bg00000gn/T/callstack-record-not-running.wav
pipeline_running: False
frames: 0
channels: 1
rate: 8000
file_bytes: 44

Affected code

  • callstack/voice/audio.py:65-112 opens the output WAV before checking self._running; if _running is false, the while self._running and ... loop never executes and the method still returns the output path.
  • callstack/voice/service.py:300-309 exposes this behavior through CallSession.record(...).
  • tests/test_audio.py covers normal recording and max-duration behavior but not the inactive-pipeline case.

Expected behavior

Starting a recording while the audio pipeline is not running should return an explicit failure before creating a misleading successful recording artifact. Callers should be able to distinguish "recorded silence from an active call" from "audio bridge was unavailable".

Suggested fix direction

  • Validate AudioPipeline.running before opening/writing the WAV, and raise a dedicated or existing audio/transport error with a redacted/actionable message when inactive.
  • Optionally validate max_duration > 0 at the same boundary, because non-positive durations currently have similar empty-file semantics.
  • Add tests for inactive-pipeline recording and, if error classes are adjusted, CallSession.record() propagation.
  • Keep voicemail issue Add VoicemailBox helper for greeting and caller recording #41 in mind: the future helper should surface this failure rather than writing metadata for an empty recording.

Acceptance criteria

  • AudioPipeline.record() fails before writing a success-looking WAV when running is false.
  • CallSession.record() propagates the failure so PBX/voicemail handlers can report or recover.
  • Tests assert no empty output file is reported as success for inactive audio.
  • Optional: non-positive max_duration is rejected rather than producing a 44-byte WAV.
  • Error messages/logs do not include caller numbers, SIM identifiers, API keys, or raw modem payloads.

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/test_audio.py tests/test_call_service.py -q
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