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.
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 untilmax_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-114reads from the transport and unconditionallywf.writeframes(data); it only treatsasyncio.TimeoutErrorspecially.callstack/voice/audio.py:116-121logs completion and returns the output path even when every read returnedb''.callstack/voice/service.py:300-311exposes this throughCallSession.record()after the call/activity guard passes.Evidence
Baseline on
mainis healthy:No-hardware reproduction with a running audio pipeline whose transport returns EOF/empty bytes:
Actual output:
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
AudioPipeline.record(), detect empty reads beforewriteframes()and raiseAudioPipelineError(or another transport/audio failure) with a redacted message.Acceptance criteria
read()returnsb''raises an audio/transport failure instead of returning the WAV path.CallSession.record()propagates the failure to PBX/voicemail handlers.Verification gates
Duplicate check
I did not find an existing issue/PR for audio transport EOF during recording. Searches used:
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.