Issues Receiving Audio from Snoop Channel in Stasis App Configuration

Hi everyone,

Based on forum discussions and the Asterisk documentation, I understand that the typical setup for a Stasis app to receive and send audio involves the following channel architecture:

Setup:
Main channel:

  • A primary PJSIP channel that connects the caller to the Stasis app.

Receiving audio:

  • A snoop channel (using "spy": "out") that monitors the PJSIP channel to receive a copy of the caller’s audio.
  • An externalMedia channel that’s added to a mixing bridge with the snoop channel. This externalMedia channel forwards the received RTP packets to a specified port.

Sending audio:

  • An additional externalMedia channel bridged directly with the PJSIP channel, set up to receive incoming RTP packets from another port and send audio back to the caller.

Problem:

To start, I implemented only the “Receiving audio” configuration, but I encountered an issue. With this setup, the externalMedia channel is receiving only silence in the RTP packets.

However, if I add the PJSIP channel to the same bridge with the snoop and externalMedia channels, I start receiving audio correctly. My understanding is that this approach (adding the PJSIP channel directly to the bridge) isn’t the correct setup, as it essentially bypasses the snoop channel. It also works without the snoop channel entirely (just bridging PJSIP with externalMedia), but I suspect this configuration will cause problems when adding the “Sending audio” part later.

Has anyone else encountered this issue? Is adding the main PJSIP channel to the bridge the only solution, or is there a setup I’m missing to get the audio from the snoop channel without directly bridging the main channel?

Any insights would be greatly appreciated, as a clarification on this could be helpful to others as well.

Thank you!

If you want to receive audio FROM the caller/PJSIP channel, should this not be “in” instead? “out” would be audio TO it, and if nothing is sending audio it would be silent.

@jcolp

To be honest, that’s what I thought, but previous conversations probably confused me with all the details.

Anyway, the “in” and “both” options aren’t working for me—audio isn’t reaching the external media. It only works if I add the PJSIP channel to this bridge.

I suspect this might be related to the Asterisk version I’m using (16.28.0). Do you have any other suggestions for further debugging?

Break down the problem. Verify the assumptions. Isolate the problem. Is the PJSIP channel answered? Is audio flowing into Asterisk? Does ChanSpy work on it (it uses the same underlying functionality as a Snoop channel).

Thanks @jcolp

I have answers to a few of your questions:

  • “Is the PJSIP channel answered?” Yes, I can see the INVITE and the Answer.
  • “Is audio flowing into Asterisk?” Yes, when I add the PJSIP channel to the bridge (with the snoop and externalMedia channels), audio is coming through.

I haven’t tried ChanSpy yet—do you think it could indicate a bug with the snoop channel?

Anything is possible, though there have been no reported issues, and the underlying functionality it relies on is heavily used, plus we’ve been using it in recent versions of Asterisk perfectly fine.

@jcolp

I suspect I’ve identified an issue that may be causing problems: there’s a mismatch in audio formats between PJSIP, ExternalMedia (ulaw), and the snoop channel (slin).

I have two questions:

  1. From my understanding, the snoop channel should inherit the format of the channel it’s spying on (which is PJSIP). So why does it have a different format?
  2. How can I resolve this mismatch? Is there a way to transcode or enforce a specific format on the snoop channel?

Snoop channels are an internal construct and channel and always operate in signed linear as that is what the underlying functionality uses, there is no specifying a format to use. When bridged to something else a translation path should be set up to transcode it.

Thanks for the quick response @jcolp

I suspect the issue isn’t with the bridge but occurs earlier in the process. The snoop channel is snooping on a channel that has a different audio format. Is it okay for a snoop channel to monitor a channel with a different audio format?

Arkadi

Yes, provided Asterisk can transcode of course. The underlying API operates in signed linear.

@jcolp

This seems like a relatively straightforward use case:

A PJSIP channel with a snoop channel set to spy (“spy”: “in”) on it, and an externalMedia channel bridged to the snoop channel.

It appears the issue lies with the snoop channel itself. When I create a bridge containing all three channels, the audio comes through, but I suspect this setup bypasses the snoop channel, allowing audio to flow directly from the PJSIP channel.

In this situation, what would you suggest looking into?

Thank you for your insights!

You can see if a core debug log shows anything[1], otherwise it would be updating to an actual supported version to ensure past issues aren’t being encountered. If you can’t update then personally I’ve run out of stuff, as I don’t like to investigate issues in old versions. Providing a sample ARI application that actually reproduces the issue can also be useful for people to assist.

[1] Collecting Debug Information - Asterisk Documentation

Thanks,

I updated to Asterisk 22.0 to verify that the version is not the issue, but the problem still persists. I have collected debug information [1] and the logs appear normal. I am attaching the logs and the ARI application [2] that reproduces the issue."

Would you like me to help review any other text?

[1]
Asterisk-22-0-no-audio.txt (89.5 KB)

[2] Python code

def on_message(ws, message):
    try:
        logging.info("\n--- New on_message execution ---")
        event = json.loads(message)
        logging.info(f"Received event: {event}")

        if event['type'] == 'StasisStart':
            channel = event['channel']
            channel_id = channel['id']
            channel_name = channel['name']

            # Ensure no redundant processing of channels
            if channel_id in processed_channels:
                logging.info(f"Ignoring already processed or snoop channel: {channel_id}")
                logging.info("--- Exit on_message: Ignoring channel ---\n")
                return

            processed_channels.add(channel_id)
            logging.info(f"Processing new channel: {channel_id}")

            # Answer the call
            answer_call(channel_id)

            # Start external media and add to processed channels
            external_channel_id = start_external_media()
            if not external_channel_id:
                logging.error("Failed to start external media.")
                logging.info("--- Exit on_message: Failed external media ---\n")
                return
            processed_channels.add(external_channel_id)

            # Create snoop channel and add to processed channels
            snoop_channel_id = create_snoop_channel(channel_id)
            if not snoop_channel_id:
                logging.error("Failed to create snoop channel.")
                logging.info("--- Exit on_message: Failed snoop channel ---\n")
                return
            processed_channels.add(snoop_channel_id)

            # Create and populate bridge
            bridge_id = create_bridge()
            if bridge_id:
                logging.info(f"Bridge {bridge_id} created. Adding channels...")

                # Add original channel to the bridge
#                add_channel_to_bridge(bridge_id, channel_id)

                # Add snoop channel to the bridge
                add_channel_to_bridge(bridge_id, snoop_channel_id)

                # Add external media channel to the bridge
                add_channel_to_bridge(bridge_id, external_channel_id)

                logging.info(f"Channels added to bridge {bridge_id}.")
            else:
                logging.error("Failed to create bridge.")
                logging.info("--- Exit on_message: Failed to create bridge ---\n")

        logging.info("--- End of on_message execution ---\n")
    except Exception as e:
        logging.error(f"Error in on_message: {e}")
        logging.info("--- Exit on_message: Exception occurred ---\n")

def start_external_media():
    """Start an external media channel."""
    url = f"{ARI_URL}/ari/channels/externalMedia"
    data = {
        "app": APP_NAME,
        "external_host": f"{MEDIA_HOST}:{MEDIA_PORT}",
        "format": "ulaw"  # Verify this matches PCMU (G.711) as per SIP SDP
    }
    logging.info(f"Opening external media with data: {data}")
    response = requests.post(url, auth=(USERNAME, PASSWORD), json=data)

    if response.status_code == 200:
        external_channel = response.json()
        logging.info(f"Started external media: {external_channel}")
        return external_channel['id']
    else:
        logging.error(f"Failed to start external media: {response.status_code} - {response.text}")
        return None

def create_snoop_channel(original_channel_id):
    url = f"{ARI_URL}/ari/channels/{original_channel_id}/snoop"
    data = {"app": APP_NAME, "spy": "in", "format": "ulaw"}
    response = requests.post(url, auth=(USERNAME, PASSWORD), json=data)
    if response.status_code == 200:
        snoop_channel = response.json()
        logging.info(f"Snoop channel created: {snoop_channel}")
        # Log to confirm stream
        logging.info(f"Snoop channel is set to spy on 'in' direction of channel: {original_channel_id}")
        return snoop_channel['id']
    else:
        logging.error(f"Failed to create snoop channel: {response.status_code} - {response.text}")
        return None

def answer_call(channel_id):
    """Answer the incoming call."""
    url = f"{ARI_URL}/ari/channels/{channel_id}/answer"
    response = requests.post(url, auth=(USERNAME, PASSWORD))
    if response.status_code == 204:
        logging.info(f"Answered call on channel: {channel_id}")
    else:
        logging.error(f"Failed to answer call: {response.status_code} - {response.text}")


def create_bridge():
    """Create a mixing bridge."""
    url = f"{ARI_URL}/ari/bridges"
    data = {"type": "mixing"}
    response = requests.post(url, auth=(USERNAME, PASSWORD), json=data)
    if response.status_code == 200:
        bridge = response.json()
        logging.info(f"Created bridge: {bridge}")
        return bridge['id']
    else:
        logging.error(f"Failed to create bridge: {response.status_code} - {response.text}")
        return None

def add_channel_to_bridge(bridge_id, channel_id):
    """Add a channel to the bridge."""
    url = f"{ARI_URL}/ari/bridges/{bridge_id}/addChannel"
    data = {"channel": channel_id}
    response = requests.post(url, auth=(USERNAME, PASSWORD), json=data)
    if response.status_code == 204:
        logging.info(f"Added channel {channel_id} to bridge {bridge_id}")
    else:
        logging.error(f"Failed to add channel to bridge: {response.status_code} - {response.text}")
def clean_all_channels_and_bridges():
    """Retrieve and clean up all open channels and bridges."""
    
    # Clean up channels
    channels_url = f"{ARI_URL}/ari/channels"
    channels_response = requests.get(channels_url, auth=(USERNAME, PASSWORD))
    
    if channels_response.status_code == 200:
        channels = channels_response.json()
        if not channels:
            logging.info("No open channels to clean.")
        else:
            for channel in channels:
                channel_id = channel['id']
                hangup_url = f"{channels_url}/{channel_id}"
                hangup_response = requests.delete(hangup_url, auth=(USERNAME, PASSWORD))
                
                if hangup_response.status_code == 204:
                    logging.info(f"Successfully hung up channel {channel_id}.")
                else:
                    logging.error(f"Failed to hang up channel {channel_id}: {hangup_response.status_code} - {hangup_response.text}")
    else:
        logging.error(f"Failed to retrieve channels: {channels_response.status_code} - {channels_response.text}")

    # Clean up bridges
    bridges_url = f"{ARI_URL}/ari/bridges"
    bridges_response = requests.get(bridges_url, auth=(USERNAME, PASSWORD))
    
    if bridges_response.status_code == 200:
        bridges = bridges_response.json()
        if not bridges:
            logging.info("No open bridges to clean.")
        else:
            for bridge in bridges:
                bridge_id = bridge['id']
                delete_bridge_url = f"{bridges_url}/{bridge_id}"
                delete_bridge_response = requests.delete(delete_bridge_url, auth=(USERNAME, PASSWORD))
                
                if delete_bridge_response.status_code == 204:
                    logging.info(f"Successfully deleted bridge {bridge_id}.")
                else:
                    logging.error(f"Failed to delete bridge {bridge_id}: {delete_bridge_response.status_code} - {delete_bridge_response.text}")
    else:
        logging.error(f"Failed to retrieve bridges: {bridges_response.status_code} - {bridges_response.text}")


def start_ari_ws():
    """Start the ARI WebSocket connection."""
    credentials = f"{USERNAME}:{PASSWORD}".encode('utf-8')
    auth_header = base64.b64encode(credentials).decode('utf-8')

    ws_url = f"ws://127.0.0.1:8088/ari/events?app={APP_NAME}&subscribeAll=true"
    ws = WebSocketApp(
        ws_url,
        on_message=on_message,
        on_error=lambda ws, err: logging.error(f"WebSocket error: {err}"),
        on_close=lambda ws, code, msg: logging.info("WebSocket connection closed"),
        header=[f"Authorization: Basic {auth_header}"]
    )
    ws.on_open = lambda ws: logging.info(f"WebSocket connected to ARI app '{APP_NAME}'")
    ws.run_forever()

if __name__ == "__main__":
    logging.info(f"Starting ARI client for app '{APP_NAME}'...")
    clean_all_channels_and_bridges()
    start_ari_ws()```

Did you try ChanSpy as I mentioned?

Otherwise, I have nothing else to add as of this time. If that changes then I will respond.

Unfortunately, I don’t currently have the resources to perform a full test with ChanSpy, as I don’t have multiple extensions or phone numbers available for calls.

I did try a simpler approach by modifying the dial plan to whisper to the caller, and that worked well.

If anyone could offer any advice or suggestions, I would be truly grateful.

Thank you very much!

I think I may have a new direction.

In my setup, the interaction is solely between the caller and an AI agent.

So, when the PJSIP channel isn’t connected to the bridge (where the snoop channel and externalMedia channel are located), the PJSIP channel doesn’t transmit audio. As @jcolp
mentioned in another post, this happens because there’s no second peer for this channel.

Does that seem correct?

If so, what’s the best practice for handling a Caller-to-AI Agent setup where there’s no second peer on the caller channel?

Thanks for any guidance!

I don’t know what “Caller-to-AI Agent” means. If they’re connecting to such a thing and it’s over external media, why is the Snoop channel in use?

Apologies for not clarifying earlier. By “Caller-to-AI Agent setup,” I mean that callers dial in, and an AI agent answers the calls.

I added the snoop channel after reading another post where you (@jcolp) mentioned that sending and receiving audio on the same PJSIP channel can cause issues and delays because audio cannot be sent while it’s being received. I’m not entirely sure I understand why, but it seems there’s a conflict when trying to read and write simultaneously to the PJSIP channel.

Does that sound correct?

Thank you for your help!

There’s not enough context to be able to say that. If it’s just being bridged to another channel such as external media then it’s fine.

I’m working on creating a wrapper for call management with Asterisk. The requirements are:

  1. Answer incoming calls
  2. Redirect/Transfer calls
  3. Receive the caller’s audio (for real-time transcription using a speech-to-text engine)
  4. Send audio to the caller (generated by our text-to-speech engine)

That’s essentially it—there are no conference calls or similar features involved. But everything should be done with REST-ful API.

I tried bridging the PJSIP channel to the externalMedia channel, as you suggested, but in this setup, I’m not receiving the audio. Only when I add the snoop channel does the audio start transmitting to the externalMedia channel.

Thanks for your assistance!