Skip to content

Treat audio recording EOF as a failure instead of empty WAV success #119

Description

@Justinabox

Summary

AudioPipeline.record() treats an audio-transport EOF/empty read (b'') as a successful zero-byte audio chunk. While the pipeline is marked running, it keeps looping until max_duration, then returns a valid-looking 44-byte WAV with zero frames.

For unattended PBX/voicemail/call-recording flows, an audio serial disconnect or closed PCM stream should fail closed. Reporting an empty recording path as success makes downstream voicemail/storage handlers believe they captured caller audio when the audio port was actually gone.

Affected code

  • callstack/voice/audio.py:102-114 reads from the transport and unconditionally wf.writeframes(data); it only treats asyncio.TimeoutError specially.
  • callstack/voice/audio.py:116-121 logs completion and returns the output path even when every read returned b''.
  • callstack/voice/service.py:300-311 exposes this through CallSession.record() after the call/activity guard passes.

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
560 passed in 6.26s

No-hardware reproduction with a running audio pipeline whose transport returns EOF/empty bytes:

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

class EOFTransport:
    def __init__(self):
        self.reads = 0
        self.opened = False
    async def open(self):
        self.opened = True
    async def close(self):
        self.opened = False
    async def write(self, data):
        pass
    async def read(self, size=-1):
        self.reads += 1
        await asyncio.sleep(0)
        return b''
    async def readline(self):
        return b''
    def in_waiting(self):
        return 0

async def main():
    out = os.path.join(tempfile.gettempdir(), 'callstack-audio-eof-record.wav')
    try:
        os.remove(out)
    except FileNotFoundError:
        pass
    transport = EOFTransport()
    pipeline = AudioPipeline(transport, EventBus())
    await pipeline.start()
    try:
        result = await pipeline.record(out, max_duration=0.02)
        print('record_returned:', result)
        print('pipeline_running_after_record:', pipeline.running)
        print('empty_reads:', transport.reads)
        print('file_exists:', os.path.exists(out))
        with wave.open(out, 'rb') as wf:
            print('frames:', wf.getnframes())
            print('channels:', wf.getnchannels())
            print('rate:', wf.getframerate())
        print('file_bytes:', os.path.getsize(out))
    finally:
        await pipeline.stop()

asyncio.run(main())
PY

Actual output:

record_returned: /var/folders/n7/73kdk745271b818g855__bg00000gn/T/callstack-audio-eof-record.wav
pipeline_running_after_record: True
empty_reads: 342
file_exists: True
frames: 0
channels: 1
rate: 8000
file_bytes: 44

Expected behavior

When a running audio transport returns b'', recording should treat it as EOF/disconnect and fail closed instead of writing/returning a success-looking zero-frame WAV.

Suggested fix direction

  • In AudioPipeline.record(), detect empty reads before writeframes() and raise AudioPipelineError (or another transport/audio failure) with a redacted message.
  • Consider marking the pipeline not running or otherwise surfacing the failure so call handlers can hang up/recover.
  • Ensure partial files are not reported as successful recordings. If retaining a partial file is useful, make that explicit and do not return it through the normal success path.

Acceptance criteria

  • A running pipeline whose transport read() returns b'' raises an audio/transport failure instead of returning the WAV path.
  • No 44-byte/0-frame file is reported as a successful call recording on EOF.
  • CallSession.record() propagates the failure to PBX/voicemail handlers.
  • Regression tests cover audio EOF/empty-read behavior without serial hardware.

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 -q
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q

Duplicate check

I did not find an existing issue/PR for audio transport EOF during recording. Searches used:

gh issue list --repo Justinabox/Callstack --state all --search 'audio record EOF empty WAV transport read empty bytes in:title,body'
gh issue list --repo Justinabox/Callstack --state all --search 'AudioPipeline record empty WAV audio port disconnect in:title,body'
gh issue list --repo Justinabox/Callstack --state all --search 'voice recording transport EOF zero frames in:title,body'
gh pr list --repo Justinabox/Callstack --state all --search 'audio EOF record empty WAV'

Related but distinct prior work: inactive-pipeline and non-positive-duration recording guards fail before recording starts; this issue is about EOF after AudioPipeline.start() has marked the audio path running.

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