CyCoreSystems / ari

Golang Asterisk REST Interface (ARI) library
Apache License 2.0
189 stars 73 forks source link

Support for Dynamically Adding AudioSocket Endpoint to Dialplan via ARI #172

Closed MihaiSandor closed 6 months ago

MihaiSandor commented 6 months ago

Hello @Ulexus,

I am currently working on an application using the ARI (Asterisk REST Interface) that involves handling voice calls in real-time. My goal is to enhance the interactivity of voice calls by directly integrating real-time microphone input from the call handler side and streaming it to the call recipient. Additionally, I aim to capture the audio stream from the call recipient and output it through the call handler's speakers. This setup is intended to enable a live and interactive audio exchange between the call handler and the recipient.

To achieve this, I am interested in dynamically adding an AudioSocket endpoint to the dialplan within the context of a Stasis Application, after a call has been answered. Here is a snippet of code illustrating where I want to integrate this functionality:

package main

import (
    "context"
    "github.com/CyCoreSystems/ari/v6"
    "github.com/CyCoreSystems/ari/v6/client/native"
    "github.com/CyCoreSystems/ari/v6/ext/record"
    "golang.org/x/exp/slog"
    "log"
    "os"
)

var (
    appLogger = slog.New(slog.NewTextHandler(os.Stderr, nil))
    ariClient ari.Client
)

func init() {
    log.Println("Connecting")
    var err error

    ariClient, err = native.Connect(&native.Options{
        Application:  os.Getenv("ARI_APP"),
        Username:     os.Getenv("ARI_USER"),
        Password:     os.Getenv("ARI_PASSWORD"),
        URL:          os.Getenv("ARI_HTTP_ENDPOINT"),
        WebsocketURL: os.Getenv("ARI_WS_ENDPOINT"),
        Logger:       appLogger,
    })
    if err != nil {
        log.Printf("Failed to connect: %v", err)
        return
    }

    log.Println("Connected")
    go answerCalls()
}

func answerCalls() {
    ctx := context.Background()
    log.Println("Listening for new calls")
    sub := ariClient.Bus().Subscribe(nil, "StasisStart")
    defer sub.Cancel()

    for {
        select {
        case e := <-sub.Events():
            v := e.(*ari.StasisStart)
            log.Printf("Got stasis start %v", v.Channel.ID)
            go handleCall(ctx, ariClient.Channel().Get(v.Key(ari.ChannelKey, v.Channel.ID)))
        case <-ctx.Done():
            log.Println("Shutting down")
            return
        }
    }
}

func handleCall(ctx context.Context, h *ari.ChannelHandle) {
    defer h.Hangup()
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    log.Printf("Running app for channel %s", h.ID())

    // Subscribe to StasisEnd event to know when the call ends
    end := h.Subscribe(ari.Events.StasisEnd)
    defer end.Cancel()

    // Answer the call
    if err := h.Answer(); err != nil {
        log.Printf("Failed to answer call %v", err)
        return
    }
    channelData, err := h.Data() // Get the channel data, which includes state and variables
    if err != nil {
        log.Printf("Failed to get channel data %v", err)
        return
    }

       // HERE I WANT TO DO SOMETHING LIKE THAT:
    _, err = ariClient.Asterisk().ExecuteApplication(ctx, channelData.ID, "AudioSocket", "5754647d-1a22-41e8-a3d8-64aad3d4a876,test:9000")
    if err != nil {
        log.Println("Failed to initiate AudioSocket", err)
        return
    }

    // Start recording and receive a handle to the recording
    recHandle := record.Record(ctx, h,
        record.TerminateOn("none"),
        record.IfExists("overwrite"),
        record.WithLogger(appLogger.With("app", "recorder")),
    )

    // Wait for the call to end
    <-end.Events()
    log.Println("Call ended, stopping recording")

    res, err := recHandle.Result()
    if err != nil {
        log.Printf("Failed to stop recording %v", err)
        return
    }

    recordName := "new-call"
    if err = res.Save(recordName); err != nil {
        log.Printf("failed to save recording %v", err)
        return
    }

    log.Printf("Call recording saved to %s", recordName)

}

However, I am uncertain about the feasibility and correct approach to dynamically add an AudioSocket application invocation to the dialplan for an ongoing call within a Stasis Application. Specifically, I am looking to understand:

  1. Is it currently possible to dynamically add an AudioSocket endpoint to the dialplan for a call that has already been answered and is being managed within a Stasis Application?
  2. If so, could you provide guidance or examples on how to properly implement this feature using the ARI and the Go client library (github.com/CyCoreSystems/ari/v6)?

The primary objective is to create a seamless real-time voice exchange between the call handler and recipient, enhancing the interactive capabilities of voice applications built on Asterisk and ARI.

Thank you for your time and assistance. Any insights, guidance, or references to relevant documentation would be greatly appreciated.

MihaiSandor commented 6 months ago

@Ulexus, I managed to add this after I answered the call, but for some reason, it enters an infinite loop and Asterisk keeps sending the StasisStart event, even though I should get the stream back to the TCP Server.

id := uuid.Must(uuid.NewV1()).String()
    _, err = h.ExternalMedia(ari.ExternalMediaOptions{
        ChannelID:      id,
        App:            ariClient.ApplicationName(),
        ExternalHost:   "test:9000",
        Format:         "slin16",
        Encapsulation:  "audiosocket",
        Transport:      "tcp",
        ConnectionType: "client",
        Direction:      "both",
        Data:           id,
        Variables: map[string]string{
            "AUDIOSOCKET_ID": id,
        },
    })
    if err != nil {
        log.Printf("Failed to get external media %v", err)
        return
    }

I am trying to have full control over the backend application, which is why I want to keep the extensions.conf minimal. The extensions.conf looks like this:

[mycontext]
include => voipms-inbound

[voipms-inbound]
exten => s,1,NoOp(Call received for extension s)
same => n,Stasis(myapp)

exten => _X!,1,Stasis(myapp)

Here are some logs from Asterisk Server:

[Mar 13 14:03:24] WARNING[3161]: app.c:1941 __ast_play_and_record: No audio available on AudioSocket/test:9000-728fd0b8-e142-11ee-a15d-9600031994bb??
[Mar 13 14:03:24]     -- User hung up
[Mar 13 14:03:24]     -- x=0, open writing:  /var/spool/asterisk/recording/01hrw1zz35fvhm61cnrz2gn7kg-rc format: wav, 0x7f8684205630
[Mar 13 14:03:24] WARNING[3162]: app.c:1941 __ast_play_and_record: No audio available on AudioSocket/test:9000-7290cb97-e142-11ee-a15d-9600031994bb??
[Mar 13 14:03:24]     -- User hung up
[Mar 13 14:03:24]     -- Called test:9000/74f52185-e142-11ee-a15d-9600031994bb
[Mar 13 14:03:24]     -- x=0, open writing:  /var/spool/asterisk/recording/01hrw1zz3a3aqwg18qdn0e3q7s-rc format: wav, 0x55ac1ff88300
[Mar 13 14:03:24]     -- AudioSocket/test:9000-74f52185-e142-11ee-a15d-9600031994bb answered
[Mar 13 14:03:24]     -- Called test:9000/74f60149-e142-11ee-a15d-9600031994bb
[Mar 13 14:03:24]     -- AudioSocket/test:9000-74f60149-e142-11ee-a15d-9600031994bb answered
[Mar 13 14:03:24] WARNING[3165]: app.c:1941 __ast_play_and_record: No audio available on AudioSocket/test:9000-7291b111-e142-11ee-a15d-9600031994bb??
[Mar 13 14:03:24]     -- User hung up
[Mar 13 14:03:24]     -- x=0, open writing:  /var/spool/asterisk/recording/01hrw1zz3h1nvyn8xxzhnc337m-rc format: wav, 0x7f8650635490
[Mar 13 14:03:24]     -- Called test:9000/74f71422-e142-11ee-a15d-9600031994bb
[Mar 13 14:03:24]     -- AudioSocket/test:9000-74f71422-e142-11ee-a15d-9600031994bb answered
[Mar 13 14:03:24]     -- x=0, open writing:  /var/spool/asterisk/recording/01hrw1zz3q2rbrx8jbr36gsc61-rc format: wav, 0x7f865c3866e0
[Mar 13 14:03:24] WARNING[3166]: app.c:1941 __ast_play_and_record: No audio available on AudioSocket/test:9000-72926c20-e142-11ee-a15d-9600031994bb??
[Mar 13 14:03:24]     -- User hung up
[Mar 13 14:03:24]     -- Called test:9000/74f80a39-e142-11ee-a15d-9600031994bb
[Mar 13 14:03:24]     -- AudioSocket/test:9000-74f80a39-e142-11ee-a15d-9600031994bb answered
[Mar 13 14:03:24]     -- x=0, open writing:  /var/spool/asterisk/recording/01hrw1zz3y66jt383z241t2fqy-rc format: wav, 0x7f8664231910
[Mar 13 14:03:24] WARNING[3169]: app.c:1941 __ast_play_and_record: No audio available on AudioSocket/test:9000-729354e5-e142-11ee-a15d-9600031994bb??
[Mar 13 14:03:24]     -- User hung up
[Mar 13 14:03:24] WARNING[3171]: app.c:1941 __ast_play_and_record: No audio available on AudioSocket/test:9000-7295a6f0-e142-11ee-a15d-9600031994bb??
[Mar 13 14:03:24]     -- User hung up
[Mar 13 14:03:24]     -- Called test:9000/74faa9c9-e142-11ee-a15d-9600031994bb
[Mar 13 14:03:24]     -- AudioSocket/test:9000-74faa9c9-e142-11ee-a15d-9600031994bb answered
[Mar 13 14:03:24] WARNING[3173]: app.c:1941 __ast_play_and_record: No audio available on AudioSocket/test:9000-7296caa0-e142-11ee-a15d-9600031994bb??
[Mar 13 14:03:24]     -- User hung up
[Mar 13 14:03:24] WARNING[3175]: app.c:1941 __ast_play_and_record: No audio available on AudioSocket/test:9000-7297c552-e142-11ee-a15d-9600031994bb??
[Mar 13 14:03:24]     -- User hung up
alon7 commented 6 months ago

@MihaiSandor I also see the loop. It's happening because ExternalMedia is calling ApplicationName with the same application name, so it just loops. I see that's happening in the example code as well and not sure how to address it.

Were you able to workaround it?

MihaiSandor commented 6 months ago

@MihaiSandor I also see the loop. It's happening because ExternalMedia is calling ApplicationName with the same application name, so it just loops. I see that's happening in the example code as well and not sure how to address it.

Were you able to workaround it?

@alon7, Yes. I did this hack:

func isUUID(uuid string) bool {
    r, err := regexp.Compile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
    if err != nil {
        log.Println("Error compiling regex:", err)
        return false
    }
    return r.MatchString(uuid)
}

func answerCalls() {
    log.Println("Listening for new calls")
    sub := ariClient.Bus().Subscribe(nil, "StasisStart")
    defer sub.Cancel()

    for {
        select {
        case e := <-sub.Events():
            v := e.(*ari.StasisStart)
            h := ariClient.Channel().Get(v.Key(ari.ChannelKey, v.Channel.ID))
            if isUUID(h.ID()) {
                log.Printf("Ignoring ExternalMedia StasisStart event for channel %s", h.ID())
                continue
            }
            go handleCall(h)
        }
    }
}
alon7 commented 6 months ago

Thanks @MihaiSandor ill try it soon!

Ulexus commented 6 months ago

Yeah, I generally set a channel variable on the newly-created channel indicating it is related to an existing channel already in ARI, and should thus be handled differently. Your workaround is also fine, but it would be better to use something a little more direct than just testing whether a new channel ID is a UUID.

Another option I have used is to pre-register the new channel ID upon creation, popping it into a redis cache, and checking new channels against that.

Ulexus commented 6 months ago

In your case, since you are setting AUDIOSOCKET_ID, you could just check for that variable on new calls.

alon7 commented 6 months ago

Makes sense @Ulexus. How come it works in your example without ignoring it?

Ulexus commented 6 months ago

That's actually a really good question... I see I'm passing "noop" as the args (another method I've used to have it ignore)... but I'm not actually evaluating that anywhere that I see. Looks like a bug. :)

MihaiSandor commented 6 months ago

Thanks @Ulexus!