From 2a61b16474b66bcd6f9c21ea3428495ae44c21c5 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:54:13 -0300 Subject: [PATCH 001/266] feat(call): add per-instance VoIP runtime registry --- pkg/call/runtime/runtime.go | 178 ++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 pkg/call/runtime/runtime.go diff --git a/pkg/call/runtime/runtime.go b/pkg/call/runtime/runtime.go new file mode 100644 index 00000000..1064eb78 --- /dev/null +++ b/pkg/call/runtime/runtime.go @@ -0,0 +1,178 @@ +package call_runtime + +import ( + "sort" + "sync" + "time" + + "go.mau.fi/whatsmeow" +) + +// State represents the lifecycle state of a WhatsApp call. +type State string + +const ( + StateIdle State = "idle" + StateRinging State = "ringing" + StateConnecting State = "connecting" + StateActive State = "active" + StateEnded State = "ended" + StateFailed State = "failed" +) + +// Direction identifies whether a call was created locally or received from a peer. +type Direction string + +const ( + DirectionIncoming Direction = "incoming" + DirectionOutgoing Direction = "outgoing" +) + +// Call contains transport-independent call state. The AstraCalls adapter will +// update these records while the existing Evolution event producers publish them. +type Call struct { + ID string `json:"id"` + Peer string `json:"peer"` + Direction Direction `json:"direction"` + State State `json:"state"` + Video bool `json:"video"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Snapshot is a safe, serializable view of one instance runtime. +type Snapshot struct { + InstanceID string `json:"instanceId"` + Connected bool `json:"connected"` + Calls []Call `json:"calls"` +} + +// Runtime owns the VoIP state associated with exactly one Evolution instance. +// It deliberately reuses the instance's existing whatsmeow client so messaging +// and calls share a single authenticated WhatsApp session. +type Runtime struct { + mu sync.RWMutex + instanceID string + client *whatsmeow.Client + calls map[string]Call +} + +func New(instanceID string, client *whatsmeow.Client) *Runtime { + return &Runtime{ + instanceID: instanceID, + client: client, + calls: make(map[string]Call), + } +} + +func (r *Runtime) InstanceID() string { + return r.instanceID +} + +// AttachClient replaces the client after an Evolution instance reconnects. +// Active media resources must be torn down by the future AstraCalls adapter +// before this method is called. +func (r *Runtime) AttachClient(client *whatsmeow.Client) { + r.mu.Lock() + r.client = client + r.mu.Unlock() +} + +func (r *Runtime) Client() *whatsmeow.Client { + r.mu.RLock() + defer r.mu.RUnlock() + return r.client +} + +// UpsertCall creates or updates a call while preserving its creation time. +func (r *Runtime) UpsertCall(call Call) { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now().UTC() + if current, ok := r.calls[call.ID]; ok { + if call.CreatedAt.IsZero() { + call.CreatedAt = current.CreatedAt + } + } else if call.CreatedAt.IsZero() { + call.CreatedAt = now + } + + if call.UpdatedAt.IsZero() { + call.UpdatedAt = now + } + r.calls[call.ID] = call +} + +func (r *Runtime) Call(callID string) (Call, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + call, ok := r.calls[callID] + return call, ok +} + +func (r *Runtime) RemoveCall(callID string) { + r.mu.Lock() + delete(r.calls, callID) + r.mu.Unlock() +} + +func (r *Runtime) Snapshot() Snapshot { + r.mu.RLock() + defer r.mu.RUnlock() + + calls := make([]Call, 0, len(r.calls)) + for _, call := range r.calls { + calls = append(calls, call) + } + sort.Slice(calls, func(i, j int) bool { + return calls[i].CreatedAt.Before(calls[j].CreatedAt) + }) + + connected := r.client != nil && r.client.IsConnected() + return Snapshot{ + InstanceID: r.instanceID, + Connected: connected, + Calls: calls, + } +} + +// Registry stores one Runtime per Evolution instance. +type Registry struct { + mu sync.RWMutex + runtimes map[string]*Runtime +} + +func NewRegistry() *Registry { + return &Registry{runtimes: make(map[string]*Runtime)} +} + +// Attach returns the existing runtime or creates it. On reconnect it updates the +// runtime to point at the newly-created whatsmeow client. +func (r *Registry) Attach(instanceID string, client *whatsmeow.Client) *Runtime { + r.mu.Lock() + defer r.mu.Unlock() + + if runtime, ok := r.runtimes[instanceID]; ok { + runtime.AttachClient(client) + return runtime + } + + runtime := New(instanceID, client) + r.runtimes[instanceID] = runtime + return runtime +} + +func (r *Registry) Get(instanceID string) (*Runtime, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + runtime, ok := r.runtimes[instanceID] + return runtime, ok +} + +func (r *Registry) Remove(instanceID string) { + r.mu.Lock() + delete(r.runtimes, instanceID) + r.mu.Unlock() +} From 4a33b62d8d65678e689ae05eb765377e2aa9a5cd Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:54:30 -0300 Subject: [PATCH 002/266] test(call): cover VoIP runtime lifecycle --- pkg/call/runtime/runtime_test.go | 89 ++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 pkg/call/runtime/runtime_test.go diff --git a/pkg/call/runtime/runtime_test.go b/pkg/call/runtime/runtime_test.go new file mode 100644 index 00000000..9f261323 --- /dev/null +++ b/pkg/call/runtime/runtime_test.go @@ -0,0 +1,89 @@ +package call_runtime + +import ( + "testing" + "time" +) + +func TestRegistryAttachReusesRuntime(t *testing.T) { + registry := NewRegistry() + + first := registry.Attach("instance-1", nil) + second := registry.Attach("instance-1", nil) + + if first != second { + t.Fatal("expected registry to reuse the runtime for the same instance") + } + if first.InstanceID() != "instance-1" { + t.Fatalf("unexpected instance id: %s", first.InstanceID()) + } +} + +func TestRuntimeUpsertPreservesCreatedAt(t *testing.T) { + runtime := New("instance-1", nil) + createdAt := time.Date(2026, time.July, 31, 12, 0, 0, 0, time.UTC) + + runtime.UpsertCall(Call{ + ID: "call-1", + Peer: "5511999999999@s.whatsapp.net", + Direction: DirectionOutgoing, + State: StateRinging, + CreatedAt: createdAt, + }) + runtime.UpsertCall(Call{ + ID: "call-1", + Peer: "5511999999999@s.whatsapp.net", + Direction: DirectionOutgoing, + State: StateActive, + }) + + call, ok := runtime.Call("call-1") + if !ok { + t.Fatal("expected call to exist") + } + if !call.CreatedAt.Equal(createdAt) { + t.Fatalf("createdAt changed: got %s want %s", call.CreatedAt, createdAt) + } + if call.State != StateActive { + t.Fatalf("unexpected state: %s", call.State) + } + if call.UpdatedAt.IsZero() { + t.Fatal("expected updatedAt to be populated") + } +} + +func TestRuntimeSnapshotIsSortedAndIndependent(t *testing.T) { + runtime := New("instance-1", nil) + later := time.Date(2026, time.July, 31, 13, 0, 0, 0, time.UTC) + earlier := later.Add(-time.Hour) + + runtime.UpsertCall(Call{ID: "later", CreatedAt: later, State: StateRinging}) + runtime.UpsertCall(Call{ID: "earlier", CreatedAt: earlier, State: StateEnded}) + + snapshot := runtime.Snapshot() + if snapshot.Connected { + t.Fatal("nil client must be reported as disconnected") + } + if len(snapshot.Calls) != 2 { + t.Fatalf("unexpected call count: %d", len(snapshot.Calls)) + } + if snapshot.Calls[0].ID != "earlier" || snapshot.Calls[1].ID != "later" { + t.Fatalf("calls are not sorted by creation time: %+v", snapshot.Calls) + } + + snapshot.Calls[0].State = StateFailed + stored, _ := runtime.Call("earlier") + if stored.State != StateEnded { + t.Fatal("snapshot mutation changed runtime state") + } +} + +func TestRegistryRemove(t *testing.T) { + registry := NewRegistry() + registry.Attach("instance-1", nil) + registry.Remove("instance-1") + + if _, ok := registry.Get("instance-1"); ok { + t.Fatal("expected runtime to be removed") + } +} From e8bd5beb8dd1be6dbd5b35d6361e93ab11bc4c05 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:54:54 -0300 Subject: [PATCH 003/266] feat(call): attach Evolution clients to VoIP runtime --- pkg/call/service/call_service.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pkg/call/service/call_service.go b/pkg/call/service/call_service.go index 3c14a948..7c1b1c06 100644 --- a/pkg/call/service/call_service.go +++ b/pkg/call/service/call_service.go @@ -5,6 +5,7 @@ import ( "errors" "time" + call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" logger_wrapper "github.com/evolution-foundation/evolution-go/pkg/logger" whatsmeow_service "github.com/evolution-foundation/evolution-go/pkg/whatsmeow/service" @@ -15,12 +16,14 @@ import ( type CallService interface { RejectCall(data *RejectCallStruct, instance *instance_model.Instance) error + RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) } type callService struct { clientPointer map[string]*whatsmeow.Client whatsmeowService whatsmeow_service.WhatsmeowService loggerWrapper *logger_wrapper.LoggerManager + runtimeRegistry *call_runtime.Registry } type RejectCallStruct struct { @@ -63,6 +66,10 @@ func (c *callService) ensureClientConnected(instanceId string) (*whatsmeow.Clien return nil, errors.New("client disconnected") } + // Calls and messaging must share the same authenticated client. Attach is + // idempotent and also replaces the pointer after an instance reconnects. + c.runtimeRegistry.Attach(instanceId, client) + c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Client successfully validated - Connected: %v", instanceId, client.IsConnected()) return client, nil } @@ -82,6 +89,16 @@ func (c *callService) RejectCall(data *RejectCallStruct, instance *instance_mode return nil } +func (c *callService) RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) { + client, err := c.ensureClientConnected(instance.Id) + if err != nil { + return call_runtime.Snapshot{InstanceID: instance.Id}, err + } + + runtime := c.runtimeRegistry.Attach(instance.Id, client) + return runtime.Snapshot(), nil +} + func NewCallService( clientPointer map[string]*whatsmeow.Client, whatsmeowService whatsmeow_service.WhatsmeowService, @@ -91,5 +108,6 @@ func NewCallService( clientPointer: clientPointer, whatsmeowService: whatsmeowService, loggerWrapper: loggerWrapper, + runtimeRegistry: call_runtime.NewRegistry(), } } From f1fc263c6a52ce4080c9caebc4579c9a6d8fdd96 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:55:09 -0300 Subject: [PATCH 004/266] feat(call): expose VoIP runtime status endpoint --- pkg/call/handler/call_handler.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pkg/call/handler/call_handler.go b/pkg/call/handler/call_handler.go index 550c3390..e33c9f2a 100644 --- a/pkg/call/handler/call_handler.go +++ b/pkg/call/handler/call_handler.go @@ -10,6 +10,7 @@ import ( type CallHandler interface { RejectCall(ctx *gin.Context) + Status(ctx *gin.Context) } type callHandler struct { @@ -51,6 +52,32 @@ func (g *callHandler) RejectCall(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"message": "success"}) } +// Runtime status +// @Summary Get VoIP runtime status +// @Description Returns the VoIP runtime attached to the authenticated Evolution instance +// @Tags Call +// @Produce json +// @Success 200 {object} call_runtime.Snapshot +// @Failure 500 {object} gin.H "Internal server error" +// @Router /call/status [get] +func (g *callHandler) Status(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + status, err := g.callService.RuntimeStatus(instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, status) +} + func NewCallHandler( callService call_service.CallService, ) CallHandler { From d4b8849bcaacd9cb2410634e4c6eca747b5c5361 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:56:20 -0300 Subject: [PATCH 005/266] feat(call): register VoIP status route --- pkg/routes/routes.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 8e026a87..f9f0a676 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -193,6 +193,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { { routes.Use(r.authMiddleware.Auth) { + routes.GET("/status", r.callHandler.Status) routes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall) } } @@ -219,8 +220,8 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { { routes.Use(r.authMiddleware.Auth) { - routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.labelHandler.ChatUnlabel) - routes.POST("/message", r.labelHandler.MessageUnlabel) + routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnlabel) + routes.POST("/message", r.messageHandler.MessageUnlabel) } } routes = eng.Group("/newsletter") From d18b1e1c16a916f0ee5f55f2e3f77691c99ef633 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:57:14 -0300 Subject: [PATCH 006/266] fix(routes): preserve label handlers while adding call status --- pkg/routes/routes.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index f9f0a676..d1a4b7d4 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -220,8 +220,8 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { { routes.Use(r.authMiddleware.Auth) { - routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnlabel) - routes.POST("/message", r.messageHandler.MessageUnlabel) + routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.labelHandler.ChatUnlabel) + routes.POST("/message", r.labelHandler.MessageUnlabel) } } routes = eng.Group("/newsletter") From 86abfbd2cac3b194b1215bbb62c0586f3bb0d43c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:57:34 -0300 Subject: [PATCH 007/266] docs(call): make runtime status Swagger annotation self-contained --- pkg/call/handler/call_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/call/handler/call_handler.go b/pkg/call/handler/call_handler.go index e33c9f2a..985977f5 100644 --- a/pkg/call/handler/call_handler.go +++ b/pkg/call/handler/call_handler.go @@ -57,7 +57,7 @@ func (g *callHandler) RejectCall(ctx *gin.Context) { // @Description Returns the VoIP runtime attached to the authenticated Evolution instance // @Tags Call // @Produce json -// @Success 200 {object} call_runtime.Snapshot +// @Success 200 {object} gin.H "VoIP runtime status" // @Failure 500 {object} gin.H "Internal server error" // @Router /call/status [get] func (g *callHandler) Status(ctx *gin.Context) { From 5a81fd2ab9229217dcd18aa82979972f29b664e8 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:03:42 -0300 Subject: [PATCH 008/266] feat(call): track whatsmeow call events in runtime --- pkg/call/runtime/runtime.go | 252 ++++++++++++++++++++++++++++++++++-- 1 file changed, 242 insertions(+), 10 deletions(-) diff --git a/pkg/call/runtime/runtime.go b/pkg/call/runtime/runtime.go index 1064eb78..bf07c47f 100644 --- a/pkg/call/runtime/runtime.go +++ b/pkg/call/runtime/runtime.go @@ -2,10 +2,14 @@ package call_runtime import ( "sort" + "strings" "sync" "time" "go.mau.fi/whatsmeow" + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" ) // State represents the lifecycle state of a WhatsApp call. @@ -36,6 +40,7 @@ type Call struct { Direction Direction `json:"direction"` State State `json:"state"` Video bool `json:"video"` + EndReason string `json:"endReason,omitempty"` Error string `json:"error,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -52,31 +57,60 @@ type Snapshot struct { // It deliberately reuses the instance's existing whatsmeow client so messaging // and calls share a single authenticated WhatsApp session. type Runtime struct { - mu sync.RWMutex - instanceID string - client *whatsmeow.Client - calls map[string]Call + mu sync.RWMutex + instanceID string + client *whatsmeow.Client + eventHandlerID uint32 + calls map[string]Call } func New(instanceID string, client *whatsmeow.Client) *Runtime { - return &Runtime{ + runtime := &Runtime{ instanceID: instanceID, - client: client, calls: make(map[string]Call), } + runtime.AttachClient(client) + return runtime } func (r *Runtime) InstanceID() string { return r.instanceID } -// AttachClient replaces the client after an Evolution instance reconnects. -// Active media resources must be torn down by the future AstraCalls adapter -// before this method is called. +// AttachClient replaces the client after an Evolution instance reconnects and +// registers an isolated call event handler on the same authenticated session. func (r *Runtime) AttachClient(client *whatsmeow.Client) { r.mu.Lock() + if r.client == client && (client == nil || r.eventHandlerID != 0) { + r.mu.Unlock() + return + } + + previousClient := r.client + previousHandlerID := r.eventHandlerID r.client = client + r.eventHandlerID = 0 + r.mu.Unlock() + + if previousClient != nil && previousHandlerID != 0 { + previousClient.RemoveEventHandler(previousHandlerID) + } + if client == nil { + return + } + + handlerID := client.AddEventHandler(r.handleEvent) + + // A reconnect can race with event-handler registration. Keep the handler only + // when this client is still the currently attached one. + r.mu.Lock() + if r.client == client { + r.eventHandlerID = handlerID + r.mu.Unlock() + return + } r.mu.Unlock() + client.RemoveEventHandler(handlerID) } func (r *Runtime) Client() *whatsmeow.Client { @@ -85,6 +119,21 @@ func (r *Runtime) Client() *whatsmeow.Client { return r.client } +// Close detaches the runtime event handler. Media teardown will be added by the +// AstraCalls driver when its transport and WebRTC resources are ported. +func (r *Runtime) Close() { + r.mu.Lock() + client := r.client + handlerID := r.eventHandlerID + r.client = nil + r.eventHandlerID = 0 + r.mu.Unlock() + + if client != nil && handlerID != 0 { + client.RemoveEventHandler(handlerID) + } +} + // UpsertCall creates or updates a call while preserving its creation time. func (r *Runtime) UpsertCall(call Call) { r.mu.Lock() @@ -105,6 +154,43 @@ func (r *Runtime) UpsertCall(call Call) { r.calls[call.ID] = call } +// Transition applies a partial lifecycle update without erasing metadata that +// was captured by an earlier call event. +func (r *Runtime) Transition(callID, peer string, direction Direction, state State, video *bool, endReason string) { + if callID == "" { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now().UTC() + call, exists := r.calls[callID] + if !exists { + call = Call{ + ID: callID, + CreatedAt: now, + } + } + if peer != "" { + call.Peer = peer + } + if direction != "" { + call.Direction = direction + } + if state != "" { + call.State = state + } + if video != nil { + call.Video = *video + } + if endReason != "" { + call.EndReason = endReason + } + call.UpdatedAt = now + r.calls[callID] = call +} + func (r *Runtime) Call(callID string) (Call, bool) { r.mu.RLock() defer r.mu.RUnlock() @@ -138,6 +224,145 @@ func (r *Runtime) Snapshot() Snapshot { } } +func (r *Runtime) handleEvent(rawEvent interface{}) { + switch event := rawEvent.(type) { + case *events.CallOffer: + video := callNodeContainsVideo(event.Data) + r.Transition( + event.CallID, + callPeer(event.CallCreator, event.From), + DirectionIncoming, + StateRinging, + &video, + "", + ) + case *events.CallOfferNotice: + video := strings.EqualFold(event.Media, "video") || callNodeContainsVideo(event.Data) + r.Transition( + event.CallID, + callPeer(event.CallCreator, event.From), + DirectionIncoming, + StateRinging, + &video, + "", + ) + case *events.CallPreAccept: + r.Transition( + event.CallID, + callPeer(event.CallCreator, event.From), + DirectionOutgoing, + StateConnecting, + nil, + "", + ) + case *events.CallAccept: + r.Transition( + event.CallID, + callPeer(event.CallCreator, event.From), + DirectionOutgoing, + StateActive, + nil, + "", + ) + case *events.CallTransport: + r.Transition( + event.CallID, + callPeer(event.CallCreator, event.From), + "", + StateConnecting, + nil, + "", + ) + case *events.CallReject: + r.Transition( + event.CallID, + callPeer(event.CallCreator, event.From), + "", + StateEnded, + nil, + "rejected", + ) + case *events.CallTerminate: + r.Transition( + event.CallID, + callPeer(event.CallCreator, event.From), + "", + StateEnded, + nil, + event.Reason, + ) + case *events.Disconnected: + r.failOpenCalls("whatsapp client disconnected") + case *events.LoggedOut: + r.failOpenCalls("whatsapp client logged out") + } +} + +func (r *Runtime) failOpenCalls(reason string) { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now().UTC() + for callID, call := range r.calls { + if call.State == StateEnded || call.State == StateFailed { + continue + } + call.State = StateFailed + call.Error = reason + call.UpdatedAt = now + r.calls[callID] = call + } +} + +func callPeer(callCreator, from types.JID) string { + if !callCreator.IsEmpty() { + return callCreator.String() + } + if !from.IsEmpty() { + return from.String() + } + return "" +} + +func callNodeContainsVideo(node *waBinary.Node) bool { + if node == nil { + return false + } + if strings.EqualFold(node.Tag, "video") { + return true + } + for key, value := range node.Attrs { + keyLower := strings.ToLower(key) + valueString := strings.ToLower(strings.TrimSpace(valueToString(value))) + if (keyLower == "media" || keyLower == "type") && valueString == "video" { + return true + } + } + + switch content := node.Content.(type) { + case []waBinary.Node: + for index := range content { + if callNodeContainsVideo(&content[index]) { + return true + } + } + case *waBinary.Node: + return callNodeContainsVideo(content) + } + return false +} + +func valueToString(value interface{}) string { + switch typed := value.(type) { + case string: + return typed + case []byte: + return string(typed) + default: + return "" + } +} + // Registry stores one Runtime per Evolution instance. type Registry struct { mu sync.RWMutex @@ -173,6 +398,13 @@ func (r *Registry) Get(instanceID string) (*Runtime, bool) { func (r *Registry) Remove(instanceID string) { r.mu.Lock() - delete(r.runtimes, instanceID) + runtime, ok := r.runtimes[instanceID] + if ok { + delete(r.runtimes, instanceID) + } r.mu.Unlock() + + if ok { + runtime.Close() + } } From 9c8391955bfe68e5eadadb59d84caf2f1cdf7818 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:04:22 -0300 Subject: [PATCH 009/266] test(call): cover whatsmeow event lifecycle --- pkg/call/runtime/runtime_test.go | 120 +++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/pkg/call/runtime/runtime_test.go b/pkg/call/runtime/runtime_test.go index 9f261323..15c8dbf6 100644 --- a/pkg/call/runtime/runtime_test.go +++ b/pkg/call/runtime/runtime_test.go @@ -3,6 +3,10 @@ package call_runtime import ( "testing" "time" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" ) func TestRegistryAttachReusesRuntime(t *testing.T) { @@ -52,6 +56,122 @@ func TestRuntimeUpsertPreservesCreatedAt(t *testing.T) { } } +func TestRuntimeTransitionPreservesCapturedMetadata(t *testing.T) { + runtime := New("instance-1", nil) + video := true + + runtime.Transition( + "call-1", + "5511999999999@s.whatsapp.net", + DirectionIncoming, + StateRinging, + &video, + "", + ) + runtime.Transition("call-1", "", DirectionOutgoing, StateActive, nil, "") + + call, ok := runtime.Call("call-1") + if !ok { + t.Fatal("expected call to exist") + } + if call.Peer != "5511999999999@s.whatsapp.net" { + t.Fatalf("peer was erased: %s", call.Peer) + } + if call.Direction != DirectionIncoming { + t.Fatalf("direction was overwritten: %s", call.Direction) + } + if !call.Video { + t.Fatal("video metadata was erased") + } + if call.State != StateActive { + t.Fatalf("unexpected state: %s", call.State) + } +} + +func TestRuntimeTracksWhatsmeowCallLifecycle(t *testing.T) { + runtime := New("instance-1", nil) + creator := types.NewJID("5511999999999", types.DefaultUserServer) + + runtime.handleEvent(&events.CallOffer{ + BasicCallMeta: types.BasicCallMeta{ + From: creator, + CallCreator: creator, + CallID: "call-1", + }, + Data: &waBinary.Node{ + Tag: "offer", + Content: []waBinary.Node{ + {Tag: "video"}, + }, + }, + }) + + call, ok := runtime.Call("call-1") + if !ok { + t.Fatal("expected call offer to create a runtime call") + } + if call.State != StateRinging || call.Direction != DirectionIncoming { + t.Fatalf("unexpected offered call: %+v", call) + } + if !call.Video { + t.Fatal("expected video call metadata") + } + + runtime.handleEvent(&events.CallAccept{ + BasicCallMeta: types.BasicCallMeta{ + From: creator, + CallCreator: creator, + CallID: "call-1", + }, + }) + + call, _ = runtime.Call("call-1") + if call.State != StateActive { + t.Fatalf("expected active state, got %s", call.State) + } + if call.Direction != DirectionIncoming { + t.Fatalf("incoming direction must be preserved, got %s", call.Direction) + } + + runtime.handleEvent(&events.CallTerminate{ + BasicCallMeta: types.BasicCallMeta{ + From: creator, + CallCreator: creator, + CallID: "call-1", + }, + Reason: "peer_hangup", + }) + + call, _ = runtime.Call("call-1") + if call.State != StateEnded { + t.Fatalf("expected ended state, got %s", call.State) + } + if call.EndReason != "peer_hangup" { + t.Fatalf("unexpected end reason: %s", call.EndReason) + } +} + +func TestRuntimeMarksOpenCallsFailedOnDisconnect(t *testing.T) { + runtime := New("instance-1", nil) + runtime.Transition("call-1", "peer", DirectionOutgoing, StateConnecting, nil, "") + runtime.Transition("call-2", "peer", DirectionIncoming, StateEnded, nil, "completed") + + runtime.handleEvent(&events.Disconnected{}) + + openCall, _ := runtime.Call("call-1") + if openCall.State != StateFailed { + t.Fatalf("expected open call to fail, got %s", openCall.State) + } + if openCall.Error == "" { + t.Fatal("expected disconnect error") + } + + endedCall, _ := runtime.Call("call-2") + if endedCall.State != StateEnded { + t.Fatalf("ended call must not change, got %s", endedCall.State) + } +} + func TestRuntimeSnapshotIsSortedAndIndependent(t *testing.T) { runtime := New("instance-1", nil) later := time.Date(2026, time.July, 31, 13, 0, 0, 0, time.UTC) From 79fd3591668ed1710610c5c60443c612d5f76286 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:05:08 -0300 Subject: [PATCH 010/266] fix(call): preserve call direction across events --- pkg/call/runtime/runtime.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/call/runtime/runtime.go b/pkg/call/runtime/runtime.go index bf07c47f..397e19df 100644 --- a/pkg/call/runtime/runtime.go +++ b/pkg/call/runtime/runtime.go @@ -175,7 +175,7 @@ func (r *Runtime) Transition(callID, peer string, direction Direction, state Sta if peer != "" { call.Peer = peer } - if direction != "" { + if direction != "" && call.Direction == "" { call.Direction = direction } if state != "" { @@ -277,7 +277,7 @@ func (r *Runtime) handleEvent(rawEvent interface{}) { r.Transition( event.CallID, callPeer(event.CallCreator, event.From), - "", + DirectionOutgoing, StateEnded, nil, "rejected", From 42145ac2a4bcc8c522ba4137ccd0afbcc2ea2329 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:05:23 -0300 Subject: [PATCH 011/266] ci(call): validate VoIP integration module --- .github/workflows/voip-integration.yml | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/voip-integration.yml diff --git a/.github/workflows/voip-integration.yml b/.github/workflows/voip-integration.yml new file mode 100644 index 00000000..139a9fa6 --- /dev/null +++ b/.github/workflows/voip-integration.yml @@ -0,0 +1,37 @@ +name: VoIP integration + +on: + push: + branches: + - dev/astracalls-integration + pull_request: + paths: + - "pkg/call/**" + - "pkg/routes/routes.go" + - "go.mod" + - "go.sum" + - ".github/workflows/voip-integration.yml" + +permissions: + contents: read + +jobs: + test-call-module: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Download dependencies + run: go mod download + + - name: Test call packages + run: go test -race ./pkg/call/... From 81d76455f9d2a5bb761d82929c535123346429f6 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:08:47 -0300 Subject: [PATCH 012/266] chore(call): preserve WaCalls MIT license --- pkg/call/voip/LICENSE-WACALLS | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 pkg/call/voip/LICENSE-WACALLS diff --git a/pkg/call/voip/LICENSE-WACALLS b/pkg/call/voip/LICENSE-WACALLS new file mode 100644 index 00000000..ae408422 --- /dev/null +++ b/pkg/call/voip/LICENSE-WACALLS @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jotadev66 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 539a5531ff4e03d16ee4eb7ff78809d723606938 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:08:58 -0300 Subject: [PATCH 013/266] feat(call): port WaCalls VoIP domain types --- pkg/call/voip/core/types.go | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 pkg/call/voip/core/types.go diff --git a/pkg/call/voip/core/types.go b/pkg/call/voip/core/types.go new file mode 100644 index 00000000..03e2a5aa --- /dev/null +++ b/pkg/call/voip/core/types.go @@ -0,0 +1,42 @@ +// Package core contains the transport-independent WhatsApp VoIP domain types. +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package core + +type CallState string + +const ( + CallStateInitiating CallState = "initiating" + CallStateRinging CallState = "ringing" + CallStateIncomingRinging CallState = "incoming_ringing" + CallStateConnecting CallState = "connecting" + CallStateActive CallState = "active" + CallStateOnHold CallState = "on_hold" + CallStateEnded CallState = "ended" +) + +type CallDirection string + +const ( + CallDirectionOutgoing CallDirection = "outgoing" + CallDirectionIncoming CallDirection = "incoming" +) + +type CallMediaType string + +const ( + CallMediaTypeAudio CallMediaType = "audio" + CallMediaTypeVideo CallMediaType = "video" +) + +type EndCallReason string + +const ( + EndCallReasonUserEnded EndCallReason = "user_ended" + EndCallReasonDeclined EndCallReason = "declined" + EndCallReasonTimeout EndCallReason = "timeout" + EndCallReasonBusy EndCallReason = "busy" + EndCallReasonCancelled EndCallReason = "cancelled" + EndCallReasonFailed EndCallReason = "failed" + EndCallReasonDoNotDisturb EndCallReason = "do_not_disturb" + EndCallReasonUnknown EndCallReason = "unknown" +) From 8b61d256c7db8de2a8d2a84e0b146a9b36daa38f Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:09:05 -0300 Subject: [PATCH 014/266] feat(call): add VoIP socket abstraction --- pkg/call/voip/core/voipsocket.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 pkg/call/voip/core/voipsocket.go diff --git a/pkg/call/voip/core/voipsocket.go b/pkg/call/voip/core/voipsocket.go new file mode 100644 index 00000000..fe3ff5b4 --- /dev/null +++ b/pkg/call/voip/core/voipsocket.go @@ -0,0 +1,25 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package core + +import ( + "context" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +// VoipSocket isolates call signaling from the Evolution instance lifecycle. +// Implementations must wrap the same whatsmeow client used by messaging. +type VoipSocket interface { + OwnPN() types.JID + OwnLID() types.JID + AccountDeviceIdentityNode() (waBinary.Node, bool) + SendNode(ctx context.Context, node waBinary.Node) error + Query(ctx context.Context, node waBinary.Node) (*waBinary.Node, error) + GetUSyncDevices(ctx context.Context, jids []types.JID) ([]types.JID, error) + AssertSessions(ctx context.Context, jids []types.JID, force bool) error + CreateParticipantNodes(ctx context.Context, devices []types.JID, callKey []byte, encAttrs waBinary.Attrs) ([]waBinary.Node, bool, error) + DecryptCallKey(ctx context.Context, from types.JID, encChild *waBinary.Node) ([]byte, error) + GetTCToken(ctx context.Context, jid types.JID) ([]byte, error) + ResolveLIDForPN(ctx context.Context, pn types.JID) types.JID +} From 59021e496f72f49bcdc252cd4e08627dbb93f598 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:09:15 -0300 Subject: [PATCH 015/266] feat(call): add WhatsApp JID helpers --- pkg/call/voip/wanode/jid.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 pkg/call/voip/wanode/jid.go diff --git a/pkg/call/voip/wanode/jid.go b/pkg/call/voip/wanode/jid.go new file mode 100644 index 00000000..ba1e6fed --- /dev/null +++ b/pkg/call/voip/wanode/jid.go @@ -0,0 +1,26 @@ +// Package wanode contains WhatsApp call-node and JID helpers. +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package wanode + +import ( + "strings" + + "go.mau.fi/whatsmeow/types" +) + +func CleanJID(jid string) string { + if index := strings.Index(jid, ":"); index >= 0 { + if at := strings.Index(jid, "@"); at > index { + return jid[:index] + jid[at:] + } + } + return jid +} + +func MustJID(value string) types.JID { + jid, err := types.ParseJID(value) + if err != nil { + return types.JID{} + } + return jid +} From 12986444500ffa8eb0b7e21e8835b081efe50972 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:09:24 -0300 Subject: [PATCH 016/266] feat(call): add WhatsApp node helpers --- pkg/call/voip/wanode/nodeutil.go | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 pkg/call/voip/wanode/nodeutil.go diff --git a/pkg/call/voip/wanode/nodeutil.go b/pkg/call/voip/wanode/nodeutil.go new file mode 100644 index 00000000..87457483 --- /dev/null +++ b/pkg/call/voip/wanode/nodeutil.go @@ -0,0 +1,61 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package wanode + +import ( + "fmt" + "strconv" + + waBinary "go.mau.fi/whatsmeow/binary" +) + +func NodeChildren(node *waBinary.Node) []waBinary.Node { + if node == nil { + return nil + } + children, _ := node.Content.([]waBinary.Node) + return children +} + +func NodeBytes(node *waBinary.Node) []byte { + if node == nil { + return nil + } + value, _ := node.Content.([]byte) + return value +} + +func AttrString(attrs waBinary.Attrs, key string) string { + value, ok := attrs[key] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + case int64: + return strconv.FormatInt(typed, 10) + case int: + return strconv.Itoa(typed) + case uint64: + return strconv.FormatUint(typed, 10) + case bool: + return strconv.FormatBool(typed) + default: + return fmt.Sprintf("%v", typed) + } +} + +func AttrInt(attrs waBinary.Attrs, key string, fallback int) int { + value, err := strconv.Atoi(AttrString(attrs, key)) + if err != nil { + return fallback + } + return value +} + +func HasAttr(attrs waBinary.Attrs, key string) bool { + value, ok := attrs[key] + return ok && value != nil && AttrString(attrs, key) != "" +} From db41b879f351b9bad9a295c9ae2bfe31366f9c96 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:09:43 -0300 Subject: [PATCH 017/266] feat(call): add call key signaling helpers --- pkg/call/voip/signaling/callkey.go | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 pkg/call/voip/signaling/callkey.go diff --git a/pkg/call/voip/signaling/callkey.go b/pkg/call/voip/signaling/callkey.go new file mode 100644 index 00000000..90d1b072 --- /dev/null +++ b/pkg/call/voip/signaling/callkey.go @@ -0,0 +1,50 @@ +// Package signaling builds and parses WhatsApp protocol nodes. +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package signaling + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + + "go.mau.fi/whatsmeow/proto/waE2E" + "google.golang.org/protobuf/proto" +) + +func GenerateCallID() string { + buffer := make([]byte, 16) + _, _ = rand.Read(buffer) + return strings.ToUpper(hex.EncodeToString(buffer)) +} + +func GenerateCallStanzaID() string { + buffer := make([]byte, 16) + _, _ = rand.Read(buffer) + return strings.ToUpper(hex.EncodeToString(buffer)) +} + +func GenerateCallKey() ([]byte, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate call key: %w", err) + } + return key, nil +} + +func EncodeCallKeyMessage(callKey []byte) ([]byte, error) { + message := &waE2E.Message{Call: &waE2E.Call{CallKey: callKey}} + return proto.Marshal(message) +} + +func DecodeCallKeyPlaintext(plaintext []byte) ([]byte, error) { + var message waE2E.Message + if err := proto.Unmarshal(plaintext, &message); err != nil { + return nil, err + } + key := message.GetCall().GetCallKey() + if len(key) != 32 { + return nil, fmt.Errorf("invalid call key: expected 32 bytes, got %d", len(key)) + } + return key, nil +} From 0fd9fb3a6c7448413ef6c2ab7862b2041a3c7141 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:10:14 -0300 Subject: [PATCH 018/266] feat(call): build WhatsApp call signaling nodes --- pkg/call/voip/signaling/build.go | 108 +++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 pkg/call/voip/signaling/build.go diff --git a/pkg/call/voip/signaling/build.go b/pkg/call/voip/signaling/build.go new file mode 100644 index 00000000..3acd80de --- /dev/null +++ b/pkg/call/voip/signaling/build.go @@ -0,0 +1,108 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package signaling + +import ( + "context" + "fmt" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode" + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +var capabilityOffer = []byte{0x01, 0x05, 0xf7, 0x09, 0xe4, 0xbb, 0x07} + +func BuildOfferStanza(ctx context.Context, socket core.VoipSocket, callID string, callKey []byte, peer types.JID, video bool) (waBinary.Node, error) { + creator := socket.OwnLID() + if creator.IsEmpty() { + creator = socket.OwnPN() + } + if creator.IsEmpty() { + return waBinary.Node{}, fmt.Errorf("whatsapp client has no own JID") + } + + resolvedPeer := socket.ResolveLIDForPN(ctx, peer) + devices, err := socket.GetUSyncDevices(ctx, []types.JID{resolvedPeer}) + if err != nil { + return waBinary.Node{}, fmt.Errorf("get peer devices: %w", err) + } + if len(devices) == 0 { + return waBinary.Node{}, fmt.Errorf("no WhatsApp devices found for %s", peer.String()) + } + if err := socket.AssertSessions(ctx, devices, false); err != nil { + return waBinary.Node{}, fmt.Errorf("assert sessions: %w", err) + } + + participants, includeIdentity, err := socket.CreateParticipantNodes( + ctx, + devices, + callKey, + waBinary.Attrs{"count": "0"}, + ) + if err != nil { + return waBinary.Node{}, fmt.Errorf("encrypt call key: %w", err) + } + + content := make([]waBinary.Node, 0, 8) + if token, tokenErr := socket.GetTCToken(ctx, wanode.MustJID(wanode.CleanJID(resolvedPeer.String()))); tokenErr == nil && len(token) > 0 { + content = append(content, waBinary.Node{Tag: "privacy", Content: token}) + } + content = append(content, + waBinary.Node{Tag: "audio", Attrs: waBinary.Attrs{"enc": "opus", "rate": "8000"}}, + waBinary.Node{Tag: "audio", Attrs: waBinary.Attrs{"enc": "opus", "rate": "16000"}}, + ) + if video { + content = append(content, waBinary.Node{Tag: "video", Attrs: waBinary.Attrs{ + "enc": "vp8", + "dec": "vp8", + "orientation": "0", + "screen_width": "1920", + "screen_height": "1080", + "device_orientation": "0", + }}) + } + content = append(content, + waBinary.Node{Tag: "net", Attrs: waBinary.Attrs{"medium": "3"}}, + waBinary.Node{Tag: "capability", Attrs: waBinary.Attrs{"ver": "1"}, Content: capabilityOffer}, + waBinary.Node{Tag: "destination", Content: participants}, + waBinary.Node{Tag: "encopt", Attrs: waBinary.Attrs{"keygen": "2"}}, + ) + if includeIdentity { + if identity, ok := socket.AccountDeviceIdentityNode(); ok { + content = append(content, identity) + } + } + + return waBinary.Node{ + Tag: "call", + Attrs: waBinary.Attrs{"to": resolvedPeer, "id": GenerateCallStanzaID()}, + Content: []waBinary.Node{{ + Tag: "offer", + Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator}, + Content: content, + }}, + }, nil +} + +func BuildTerminateStanza(peer types.JID, callID string, creator types.JID) waBinary.Node { + return wrap(peer, waBinary.Node{ + Tag: "terminate", + Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator}, + }) +} + +func BuildRejectStanza(peer types.JID, callID string, creator types.JID) waBinary.Node { + return wrap(peer, waBinary.Node{ + Tag: "reject", + Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator}, + }) +} + +func wrap(to types.JID, inner waBinary.Node) waBinary.Node { + return waBinary.Node{ + Tag: "call", + Attrs: waBinary.Attrs{"to": to, "id": GenerateCallStanzaID()}, + Content: []waBinary.Node{inner}, + } +} From dc9ea2390698efd10ab21d4a99a00b64646d90f2 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:10:39 -0300 Subject: [PATCH 019/266] feat(call): adapt Evolution whatsmeow client for VoIP signaling --- pkg/call/voip/wa/socket.go | 139 +++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 pkg/call/voip/wa/socket.go diff --git a/pkg/call/voip/wa/socket.go b/pkg/call/voip/wa/socket.go new file mode 100644 index 00000000..dd959f22 --- /dev/null +++ b/pkg/call/voip/wa/socket.go @@ -0,0 +1,139 @@ +// Package wa adapts the Evolution whatsmeow client to the VoIP socket interface. +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package wa + +import ( + "context" + "fmt" + "time" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" + "go.mau.fi/whatsmeow" + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +const queryTimeout = 15 * time.Second + +type Socket struct { + client *whatsmeow.Client +} + +func NewSocket(client *whatsmeow.Client) *Socket { + return &Socket{client: client} +} + +var _ core.VoipSocket = (*Socket)(nil) + +func (s *Socket) dangerous() *whatsmeow.DangerousInternalClient { + return s.client.DangerousInternals() +} + +func (s *Socket) OwnPN() types.JID { return s.dangerous().GetOwnID() } +func (s *Socket) OwnLID() types.JID { return s.dangerous().GetOwnLID() } + +func (s *Socket) AccountDeviceIdentityNode() (waBinary.Node, bool) { + if s.client == nil || s.client.Store == nil || s.client.Store.Account == nil { + return waBinary.Node{}, false + } + return s.dangerous().MakeDeviceIdentityNode(), true +} + +func (s *Socket) SendNode(ctx context.Context, node waBinary.Node) error { + if s.client == nil { + return fmt.Errorf("nil whatsmeow client") + } + return s.dangerous().SendNode(ctx, node) +} + +func (s *Socket) Query(ctx context.Context, node waBinary.Node) (*waBinary.Node, error) { + id, _ := node.Attrs["id"].(string) + if id == "" { + return nil, s.SendNode(ctx, node) + } + + dangerous := s.dangerous() + responseChannel := dangerous.WaitResponse(id) + if err := dangerous.SendNode(ctx, node); err != nil { + dangerous.CancelResponse(id, responseChannel) + return nil, err + } + + timer := time.NewTimer(queryTimeout) + defer timer.Stop() + select { + case response := <-responseChannel: + return response, nil + case <-timer.C: + dangerous.CancelResponse(id, responseChannel) + return nil, nil + case <-ctx.Done(): + dangerous.CancelResponse(id, responseChannel) + return nil, ctx.Err() + } +} + +func (s *Socket) GetUSyncDevices(ctx context.Context, jids []types.JID) ([]types.JID, error) { + return s.client.GetUserDevices(ctx, jids) +} + +func (s *Socket) AssertSessions(context.Context, []types.JID, bool) error { + // whatsmeow ensures Signal sessions while encrypting for the target devices. + return nil +} + +func (s *Socket) CreateParticipantNodes(ctx context.Context, devices []types.JID, callKey []byte, attrs waBinary.Attrs) ([]waBinary.Node, bool, error) { + plaintext, err := signaling.EncodeCallKeyMessage(callKey) + if err != nil { + return nil, false, err + } + messageID := s.client.GenerateMessageID() + return s.dangerous().EncryptMessageForDevices(ctx, devices, messageID, plaintext, plaintext, attrs) +} + +func (s *Socket) DecryptCallKey(ctx context.Context, from types.JID, encrypted *waBinary.Node) ([]byte, error) { + typeValue, _ := encrypted.Attrs["type"].(string) + plaintext, _, err := s.dangerous().DecryptDM(ctx, encrypted, from, typeValue == "pkmsg", time.Now()) + if err != nil { + return nil, err + } + return signaling.DecodeCallKeyPlaintext(plaintext) +} + +func (s *Socket) GetTCToken(ctx context.Context, jid types.JID) ([]byte, error) { + if s.client.Store == nil || s.client.Store.PrivacyTokens == nil { + return nil, nil + } + candidates := []types.JID{s.ResolveLIDForPN(ctx, jid).ToNonAD(), jid.ToNonAD()} + for _, candidate := range candidates { + if candidate.IsEmpty() { + continue + } + token, err := s.client.Store.PrivacyTokens.GetPrivacyToken(ctx, candidate) + if err != nil { + return nil, err + } + if token != nil && len(token.Token) > 0 { + return token.Token, nil + } + } + return nil, nil +} + +func (s *Socket) ResolveLIDForPN(ctx context.Context, phoneNumber types.JID) types.JID { + if phoneNumber.Server == types.HiddenUserServer { + return phoneNumber + } + if s.client.Store != nil && s.client.Store.LIDs != nil { + if lid, err := s.client.Store.LIDs.GetLIDForPN(ctx, phoneNumber); err == nil && !lid.IsEmpty() { + return lid + } + } + if userInfo, err := s.client.GetUserInfo(ctx, []types.JID{phoneNumber}); err == nil { + if lid := userInfo[phoneNumber].LID; !lid.IsEmpty() { + return lid + } + } + return phoneNumber +} From 04d1d91d9a84f9c8f0b2c5c9a61ab8456589adb0 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:11:07 -0300 Subject: [PATCH 020/266] feat(call): add signaling-only call driver --- pkg/call/voip/driver/signaling.go | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 pkg/call/voip/driver/signaling.go diff --git a/pkg/call/voip/driver/signaling.go b/pkg/call/voip/driver/signaling.go new file mode 100644 index 00000000..3f98d14d --- /dev/null +++ b/pkg/call/voip/driver/signaling.go @@ -0,0 +1,59 @@ +// Package driver coordinates VoIP protocol operations without owning Evolution sessions. +package driver + +import ( + "context" + "fmt" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" +) + +// SignalingDriver sends real WhatsApp call stanzas. Media transport is not yet +// attached, so a successful offer means the peer can ring and emit lifecycle +// events, not that bidirectional audio is available. +type SignalingDriver struct { + socket core.VoipSocket +} + +func NewSignalingDriver(client *whatsmeow.Client) *SignalingDriver { + return &SignalingDriver{socket: wa.NewSocket(client)} +} + +func (d *SignalingDriver) Start(ctx context.Context, peer types.JID, video bool) (string, types.JID, error) { + if peer.IsEmpty() { + return "", types.JID{}, fmt.Errorf("peer JID is empty") + } + callID := signaling.GenerateCallID() + callKey, err := signaling.GenerateCallKey() + if err != nil { + return "", types.JID{}, err + } + resolvedPeer := d.socket.ResolveLIDForPN(ctx, peer) + offer, err := signaling.BuildOfferStanza(ctx, d.socket, callID, callKey, resolvedPeer, video) + if err != nil { + return "", types.JID{}, err + } + if err := d.socket.SendNode(ctx, offer); err != nil { + return "", types.JID{}, fmt.Errorf("send call offer: %w", err) + } + return callID, resolvedPeer, nil +} + +func (d *SignalingDriver) EndOutgoing(ctx context.Context, callID string, peer types.JID) error { + creator := d.socket.OwnLID() + if creator.IsEmpty() { + creator = d.socket.OwnPN() + } + if creator.IsEmpty() { + return fmt.Errorf("whatsapp client has no own JID") + } + node := signaling.BuildTerminateStanza(peer, callID, creator) + if err := d.socket.SendNode(ctx, node); err != nil { + return fmt.Errorf("send call terminate: %w", err) + } + return nil +} From ce92b767c1ea50b56356eb8f97e1d73a9d5aa417 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:11:42 -0300 Subject: [PATCH 021/266] feat(call): start and terminate signaling-only calls --- pkg/call/service/call_service.go | 129 +++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 22 deletions(-) diff --git a/pkg/call/service/call_service.go b/pkg/call/service/call_service.go index 7c1b1c06..29f5405a 100644 --- a/pkg/call/service/call_service.go +++ b/pkg/call/service/call_service.go @@ -3,18 +3,25 @@ package call_service import ( "context" "errors" + "fmt" "time" call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" + call_driver "github.com/evolution-foundation/evolution-go/pkg/call/voip/driver" instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" logger_wrapper "github.com/evolution-foundation/evolution-go/pkg/logger" + "github.com/evolution-foundation/evolution-go/pkg/utils" whatsmeow_service "github.com/evolution-foundation/evolution-go/pkg/whatsmeow/service" "github.com/gomessguii/logger" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" ) +const signalingTimeout = 30 * time.Second + type CallService interface { + StartCall(data *StartCallStruct, instance *instance_model.Instance) (call_runtime.Call, error) + TerminateCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) RejectCall(data *RejectCallStruct, instance *instance_model.Instance) error RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) } @@ -26,66 +33,144 @@ type callService struct { runtimeRegistry *call_runtime.Registry } +type StartCallStruct struct { + Number string `json:"number" binding:"required"` + Video bool `json:"video"` +} + type RejectCallStruct struct { CallCreator types.JID `json:"callCreator"` - CallID string `json:"callId"` + CallID string `json:"callId" binding:"required"` } -func (c *callService) ensureClientConnected(instanceId string) (*whatsmeow.Client, error) { - client := c.clientPointer[instanceId] - c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Checking client connection status - Client exists: %v", instanceId, client != nil) +func (c *callService) ensureClientConnected(instanceID string) (*whatsmeow.Client, error) { + client := c.clientPointer[instanceID] + c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Checking client connection status - Client exists: %v", instanceID, client != nil) if client == nil { - c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] No client found, attempting to start new instance", instanceId) - err := c.whatsmeowService.StartInstance(instanceId) - if err != nil { - c.loggerWrapper.GetLogger(instanceId).LogError("[%s] Failed to start instance: %v", instanceId, err) + c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] No client found, attempting to start new instance", instanceID) + if err := c.whatsmeowService.StartInstance(instanceID); err != nil { + c.loggerWrapper.GetLogger(instanceID).LogError("[%s] Failed to start instance: %v", instanceID, err) return nil, errors.New("no active session found") } - c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Instance started, waiting 2 seconds...", instanceId) + c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Instance started, waiting 2 seconds...", instanceID) time.Sleep(2 * time.Second) - client = c.clientPointer[instanceId] - c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Checking new client - Exists: %v, Connected: %v", - instanceId, + client = c.clientPointer[instanceID] + c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Checking new client - Exists: %v, Connected: %v", + instanceID, client != nil, client != nil && client.IsConnected()) if client == nil || !client.IsConnected() { - c.loggerWrapper.GetLogger(instanceId).LogError("[%s] New client validation failed - Exists: %v, Connected: %v", - instanceId, + c.loggerWrapper.GetLogger(instanceID).LogError("[%s] New client validation failed - Exists: %v, Connected: %v", + instanceID, client != nil, client != nil && client.IsConnected()) return nil, errors.New("no active session found") } } else if !client.IsConnected() { - c.loggerWrapper.GetLogger(instanceId).LogError("[%s] Existing client is disconnected - Connected status: %v", - instanceId, + c.loggerWrapper.GetLogger(instanceID).LogError("[%s] Existing client is disconnected - Connected status: %v", + instanceID, client.IsConnected()) return nil, errors.New("client disconnected") } - // Calls and messaging must share the same authenticated client. Attach is - // idempotent and also replaces the pointer after an instance reconnects. - c.runtimeRegistry.Attach(instanceId, client) + // Calls and messaging share the same authenticated client. Attach also + // installs the isolated call event handler and replaces it after reconnects. + c.runtimeRegistry.Attach(instanceID, client) - c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Client successfully validated - Connected: %v", instanceId, client.IsConnected()) + c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Client successfully validated - Connected: %v", instanceID, client.IsConnected()) return client, nil } +func (c *callService) StartCall(data *StartCallStruct, instance *instance_model.Instance) (call_runtime.Call, error) { + client, err := c.ensureClientConnected(instance.Id) + if err != nil { + return call_runtime.Call{}, err + } + + peer, ok := utils.ParseJID(data.Number) + if !ok { + return call_runtime.Call{}, fmt.Errorf("invalid WhatsApp number: %s", data.Number) + } + peer = utils.CanonicalJID(peer) + if peer.Server != types.DefaultUserServer && peer.Server != types.HiddenUserServer { + return call_runtime.Call{}, fmt.Errorf("calls only support individual WhatsApp users") + } + + ctx, cancel := context.WithTimeout(context.Background(), signalingTimeout) + defer cancel() + + driver := call_driver.NewSignalingDriver(client) + callID, resolvedPeer, err := driver.Start(ctx, peer, data.Video) + if err != nil { + return call_runtime.Call{}, err + } + + runtime := c.runtimeRegistry.Attach(instance.Id, client) + video := data.Video + runtime.Transition( + callID, + resolvedPeer.String(), + call_runtime.DirectionOutgoing, + call_runtime.StateRinging, + &video, + "", + ) + call, _ := runtime.Call(callID) + c.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Call offer sent - CallID: %s, Peer: %s, Video: %v", instance.Id, callID, resolvedPeer.String(), data.Video) + return call, nil +} + +func (c *callService) TerminateCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) { + client, err := c.ensureClientConnected(instance.Id) + if err != nil { + return call_runtime.Call{}, err + } + + runtime := c.runtimeRegistry.Attach(instance.Id, client) + call, ok := runtime.Call(callID) + if !ok { + return call_runtime.Call{}, fmt.Errorf("call %s not found", callID) + } + if call.State == call_runtime.StateEnded || call.State == call_runtime.StateFailed { + return call, nil + } + if call.Direction != call_runtime.DirectionOutgoing { + return call_runtime.Call{}, fmt.Errorf("terminating incoming calls will be enabled with the full CallManager port") + } + + peer, err := types.ParseJID(call.Peer) + if err != nil || peer.IsEmpty() { + return call_runtime.Call{}, fmt.Errorf("invalid call peer: %s", call.Peer) + } + + ctx, cancel := context.WithTimeout(context.Background(), signalingTimeout) + defer cancel() + if err := call_driver.NewSignalingDriver(client).EndOutgoing(ctx, callID, peer); err != nil { + return call_runtime.Call{}, err + } + + runtime.Transition(callID, "", "", call_runtime.StateEnded, nil, "user_ended") + call, _ = runtime.Call(callID) + return call, nil +} + func (c *callService) RejectCall(data *RejectCallStruct, instance *instance_model.Instance) error { client, err := c.ensureClientConnected(instance.Id) if err != nil { return err } - err = client.RejectCall(context.Background(), data.CallCreator, data.CallID) - if err != nil { + if err = client.RejectCall(context.Background(), data.CallCreator, data.CallID); err != nil { logger.LogError("[%s] error reject call: %v", instance.Id, err) return err } + runtime := c.runtimeRegistry.Attach(instance.Id, client) + runtime.Transition(data.CallID, data.CallCreator.String(), call_runtime.DirectionIncoming, call_runtime.StateEnded, nil, "rejected") return nil } From 015e4289643fcd840c30d5332676681a445a8804 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:12:06 -0300 Subject: [PATCH 022/266] feat(call): expose start and terminate handlers --- pkg/call/handler/call_handler.go | 102 +++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 19 deletions(-) diff --git a/pkg/call/handler/call_handler.go b/pkg/call/handler/call_handler.go index 985977f5..3d9a6d37 100644 --- a/pkg/call/handler/call_handler.go +++ b/pkg/call/handler/call_handler.go @@ -9,6 +9,8 @@ import ( ) type CallHandler interface { + StartCall(ctx *gin.Context) + TerminateCall(ctx *gin.Context) RejectCall(ctx *gin.Context) Status(ctx *gin.Context) } @@ -17,6 +19,80 @@ type callHandler struct { callService call_service.CallService } +func instanceFromContext(ctx *gin.Context) (*instance_model.Instance, bool) { + value, exists := ctx.Get("instance") + if !exists { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return nil, false + } + instance, ok := value.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return nil, false + } + return instance, true +} + +// Start call +// @Summary Start an experimental signaling-only WhatsApp call +// @Description Sends a real WhatsApp call offer. Audio transport is not implemented yet. +// @Tags Call +// @Accept json +// @Produce json +// @Param message body call_service.StartCallStruct true "Call data" +// @Success 201 {object} gin.H "Call created" +// @Failure 400 {object} gin.H "Invalid request" +// @Failure 500 {object} gin.H "Call signaling failed" +// @Router /call/start [post] +func (g *callHandler) StartCall(ctx *gin.Context) { + instance, ok := instanceFromContext(ctx) + if !ok { + return + } + + var data call_service.StartCallStruct + if err := ctx.ShouldBindJSON(&data); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + call, err := g.callService.StartCall(&data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, call) +} + +// Terminate call +// @Summary Terminate an outgoing WhatsApp call +// @Description Sends a terminate stanza for an outgoing call tracked by this instance +// @Tags Call +// @Produce json +// @Param callId path string true "Call ID" +// @Success 200 {object} gin.H "Call terminated" +// @Failure 404 {object} gin.H "Call not found" +// @Failure 500 {object} gin.H "Call signaling failed" +// @Router /call/{callId} [delete] +func (g *callHandler) TerminateCall(ctx *gin.Context) { + instance, ok := instanceFromContext(ctx) + if !ok { + return + } + callID := ctx.Param("callId") + if callID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "callId is required"}) + return + } + + call, err := g.callService.TerminateCall(callID, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, call) +} + // Reject call // @Summary Reject call // @Description Reject call @@ -28,23 +104,18 @@ type callHandler struct { // @Failure 500 {object} gin.H "Internal server error" // @Router /call/reject [post] func (g *callHandler) RejectCall(ctx *gin.Context) { - getInstance := ctx.MustGet("instance") - - instance, ok := getInstance.(*instance_model.Instance) + instance, ok := instanceFromContext(ctx) if !ok { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) return } - var data *call_service.RejectCallStruct - err := ctx.ShouldBindBodyWithJSON(&data) - if err != nil { + var data call_service.RejectCallStruct + if err := ctx.ShouldBindJSON(&data); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - err = g.callService.RejectCall(data, instance) - if err != nil { + if err := g.callService.RejectCall(&data, instance); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -61,11 +132,8 @@ func (g *callHandler) RejectCall(ctx *gin.Context) { // @Failure 500 {object} gin.H "Internal server error" // @Router /call/status [get] func (g *callHandler) Status(ctx *gin.Context) { - getInstance := ctx.MustGet("instance") - - instance, ok := getInstance.(*instance_model.Instance) + instance, ok := instanceFromContext(ctx) if !ok { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) return } @@ -78,10 +146,6 @@ func (g *callHandler) Status(ctx *gin.Context) { ctx.JSON(http.StatusOK, status) } -func NewCallHandler( - callService call_service.CallService, -) CallHandler { - return &callHandler{ - callService: callService, - } +func NewCallHandler(callService call_service.CallService) CallHandler { + return &callHandler{callService: callService} } From 27c38fa9544873f26522e19d99b282dd29a557f3 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:13:07 -0300 Subject: [PATCH 023/266] feat(call): register start and terminate routes --- pkg/routes/routes.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index d1a4b7d4..dd63418f 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -194,6 +194,8 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { routes.Use(r.authMiddleware.Auth) { routes.GET("/status", r.callHandler.Status) + routes.POST("/start", r.callHandler.StartCall) + routes.DELETE("/:callId", r.callHandler.TerminateCall) routes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall) } } From 6ed85cbac966babb60395638003a86af1c63f722 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:13:38 -0300 Subject: [PATCH 024/266] test(call): validate outgoing offer stanza --- pkg/call/voip/signaling/build_test.go | 102 ++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 pkg/call/voip/signaling/build_test.go diff --git a/pkg/call/voip/signaling/build_test.go b/pkg/call/voip/signaling/build_test.go new file mode 100644 index 00000000..fa4cd9e7 --- /dev/null +++ b/pkg/call/voip/signaling/build_test.go @@ -0,0 +1,102 @@ +package signaling + +import ( + "context" + "testing" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode" + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +type fakeSocket struct { + own types.JID + devices []types.JID +} + +func (f *fakeSocket) OwnPN() types.JID { return f.own } +func (f *fakeSocket) OwnLID() types.JID { return types.JID{} } +func (f *fakeSocket) AccountDeviceIdentityNode() (waBinary.Node, bool) { + return waBinary.Node{Tag: "device-identity"}, true +} +func (f *fakeSocket) SendNode(context.Context, waBinary.Node) error { return nil } +func (f *fakeSocket) Query(context.Context, waBinary.Node) (*waBinary.Node, error) { + return nil, nil +} +func (f *fakeSocket) GetUSyncDevices(context.Context, []types.JID) ([]types.JID, error) { + return f.devices, nil +} +func (f *fakeSocket) AssertSessions(context.Context, []types.JID, bool) error { return nil } +func (f *fakeSocket) CreateParticipantNodes(context.Context, []types.JID, []byte, waBinary.Attrs) ([]waBinary.Node, bool, error) { + return []waBinary.Node{{Tag: "to", Attrs: waBinary.Attrs{"jid": f.devices[0]}}}, true, nil +} +func (f *fakeSocket) DecryptCallKey(context.Context, types.JID, *waBinary.Node) ([]byte, error) { + return nil, nil +} +func (f *fakeSocket) GetTCToken(context.Context, types.JID) ([]byte, error) { return nil, nil } +func (f *fakeSocket) ResolveLIDForPN(_ context.Context, jid types.JID) types.JID { return jid } + +func TestGenerateCallKey(t *testing.T) { + key, err := GenerateCallKey() + if err != nil { + t.Fatalf("GenerateCallKey() error = %v", err) + } + if len(key) != 32 { + t.Fatalf("GenerateCallKey() length = %d, want 32", len(key)) + } +} + +func TestBuildOfferStanza(t *testing.T) { + own := types.NewJID("5511000000000", types.DefaultUserServer) + peer := types.NewJID("5511999999999", types.DefaultUserServer) + device := types.NewJID("5511999999999", types.DefaultUserServer) + socket := &fakeSocket{own: own, devices: []types.JID{device}} + + node, err := BuildOfferStanza(context.Background(), socket, "CALL-123", make([]byte, 32), peer, false) + if err != nil { + t.Fatalf("BuildOfferStanza() error = %v", err) + } + if node.Tag != "call" { + t.Fatalf("root tag = %q, want call", node.Tag) + } + if to, ok := node.Attrs["to"].(types.JID); !ok || to != peer { + t.Fatalf("root to = %#v, want %s", node.Attrs["to"], peer.String()) + } + + rootChildren := wanode.NodeChildren(&node) + if len(rootChildren) != 1 || rootChildren[0].Tag != "offer" { + t.Fatalf("unexpected root children: %#v", rootChildren) + } + offer := rootChildren[0] + if wanode.AttrString(offer.Attrs, "call-id") != "CALL-123" { + t.Fatalf("call-id = %q", wanode.AttrString(offer.Attrs, "call-id")) + } + if creator, ok := offer.Attrs["call-creator"].(types.JID); !ok || creator != own { + t.Fatalf("call creator = %#v, want %s", offer.Attrs["call-creator"], own.String()) + } + + var audio16, destination, identity bool + for _, child := range wanode.NodeChildren(&offer) { + switch child.Tag { + case "audio": + if wanode.AttrString(child.Attrs, "rate") == "16000" { + audio16 = true + } + case "destination": + destination = len(wanode.NodeChildren(&child)) == 1 + case "device-identity": + identity = true + } + } + if !audio16 || !destination || !identity { + t.Fatalf("offer missing required nodes: audio16=%v destination=%v identity=%v", audio16, destination, identity) + } +} + +func TestBuildOfferRequiresDevices(t *testing.T) { + socket := &fakeSocket{own: types.NewJID("5511000000000", types.DefaultUserServer)} + peer := types.NewJID("5511999999999", types.DefaultUserServer) + if _, err := BuildOfferStanza(context.Background(), socket, "CALL-123", make([]byte, 32), peer, false); err == nil { + t.Fatal("BuildOfferStanza() expected error when no peer devices are available") + } +} From 32564ad72fa479c2b01f310ecec8b299fdb5becb Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:14:50 -0300 Subject: [PATCH 025/266] docs(call): document signaling-only call API --- docs/wiki/guias-api/api-calls-experimental.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/wiki/guias-api/api-calls-experimental.md diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md new file mode 100644 index 00000000..1c7e114d --- /dev/null +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -0,0 +1,99 @@ +# API de chamadas — integração experimental + +Esta branch adiciona a primeira etapa da integração WaCalls/AstraCalls ao Evolution Go. + +> **Estado atual:** a sinalização é real e pode fazer o aparelho remoto tocar, mas o transporte de áudio/WebRTC/SRTP ainda não foi conectado. Não use em produção. + +Todas as rotas usam a autenticação normal da instância do Evolution. + +## Ativar e consultar o runtime + +```http +GET /call/status +``` + +Além de retornar as chamadas conhecidas, essa rota anexa o monitor de eventos ao `whatsmeow.Client` da instância. Nesta etapa experimental, execute-a ao menos uma vez após conectar ou reconectar a instância para monitorar chamadas recebidas antes de qualquer operação de chamada. + +Exemplo de resposta: + +```json +{ + "instanceId": "INSTANCE_ID", + "connected": true, + "calls": [] +} +``` + +## Iniciar uma chamada + +```http +POST /call/start +Content-Type: application/json +apikey: INSTANCE_TOKEN +``` + +```json +{ + "number": "5511999999999", + "video": false +} +``` + +A resposta HTTP `201` contém o `id` da chamada e o estado inicial `ringing`. + +```json +{ + "id": "32_CHARACTER_CALL_ID", + "peer": "5511999999999:DEVICE@s.whatsapp.net", + "direction": "outgoing", + "state": "ringing", + "video": false, + "createdAt": "2026-07-31T23:00:00Z", + "updatedAt": "2026-07-31T23:00:00Z" +} +``` + +## Encerrar uma chamada realizada + +```http +DELETE /call/{callId} +apikey: INSTANCE_TOKEN +``` + +Nesta etapa, o encerramento por essa rota está habilitado apenas para chamadas de saída registradas pelo runtime. + +## Rejeitar uma chamada recebida + +A rota existente foi preservada: + +```http +POST /call/reject +Content-Type: application/json +apikey: INSTANCE_TOKEN +``` + +```json +{ + "callCreator": "5511999999999@s.whatsapp.net", + "callId": "CALL_ID" +} +``` + +## Estados rastreados + +- `ringing` +- `connecting` +- `active` +- `ended` +- `failed` + +O runtime escuta `CallOffer`, `CallOfferNotice`, `CallPreAccept`, `CallAccept`, `CallTransport`, `CallReject`, `CallTerminate`, `Disconnected` e `LoggedOut` no mesmo cliente utilizado pela mensageria. + +## Limitações atuais + +- sem áudio bidirecional; +- sem WebRTC para navegador; +- sem SRTP/relay do WhatsApp; +- aceite de chamadas recebidas ainda não exposto; +- o runtime ainda precisa ser ativado por uma rota de chamadas após reconexão; +- API e formatos podem mudar enquanto o PR estiver em rascunho. From 44cf8c9a4b0d2803293368a736cdcb850cab9e08 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:08:02 -0300 Subject: [PATCH 026/266] feat(call): add private incoming call material registry --- pkg/call/voip/incoming/registry.go | 276 +++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 pkg/call/voip/incoming/registry.go diff --git a/pkg/call/voip/incoming/registry.go b/pkg/call/voip/incoming/registry.go new file mode 100644 index 00000000..0d8478bb --- /dev/null +++ b/pkg/call/voip/incoming/registry.go @@ -0,0 +1,276 @@ +// Package incoming keeps private material required to accept WhatsApp calls. +// Call keys and device metadata are intentionally separated from the public +// runtime snapshots and are never serialized. +package incoming + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +const prepareTimeout = 30 * time.Second + +type callSecret struct { + callKey []byte + peer types.JID + creator types.JID + video bool +} + +type session struct { + mu sync.RWMutex + client *whatsmeow.Client + handlerID uint32 + secrets map[string]*callSecret +} + +func newSession(client *whatsmeow.Client) *session { + s := &session{ + client: client, + secrets: make(map[string]*callSecret), + } + if client != nil { + s.handlerID = client.AddEventHandler(s.handleEvent) + } + return s +} + +func (s *session) handleEvent(rawEvent interface{}) { + switch event := rawEvent.(type) { + case *events.CallOffer: + // Decrypting the Signal payload and sending preaccept must not block the + // main Evolution event dispatcher. + go s.prepareOffer(event) + case *events.CallReject: + s.remove(event.CallID) + case *events.CallTerminate: + s.remove(event.CallID) + case *events.Disconnected: + s.clear() + case *events.LoggedOut: + s.clear() + } +} + +func (s *session) prepareOffer(event *events.CallOffer) { + if event == nil || event.CallID == "" || event.Data == nil { + return + } + + s.mu.RLock() + client := s.client + s.mu.RUnlock() + if client == nil { + return + } + + peer := event.From + creator := event.CallCreator + if creator.IsEmpty() { + creator = peer + } + if peer.IsEmpty() { + peer = creator + } + if peer.IsEmpty() || creator.IsEmpty() { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), prepareTimeout) + defer cancel() + + socket := wa.NewSocket(client) + callKey, err := signaling.DecryptCallKeyInNode(ctx, socket, event.Data, peer) + if err != nil || len(callKey) != 32 { + return + } + + secret := &callSecret{ + callKey: append([]byte(nil), callKey...), + peer: peer, + creator: creator, + video: signaling.NodeContainsVideo(event.Data), + } + s.store(event.CallID, secret) + + // WhatsApp expects preaccept before the user explicitly accepts the call. + // A send failure does not expose or discard the key; the accept endpoint will + // still return the concrete signaling error if the session is unhealthy. + _ = socket.SendNode(ctx, signaling.BuildPreacceptStanza(peer, event.CallID, creator)) +} + +func (s *session) accept(ctx context.Context, callID string) error { + secret, ok := s.copySecret(callID) + if !ok { + return fmt.Errorf("incoming call %s is not ready to accept", callID) + } + defer zeroBytes(secret.callKey) + + s.mu.RLock() + client := s.client + s.mu.RUnlock() + if client == nil { + return fmt.Errorf("incoming call session is detached") + } + + socket := wa.NewSocket(client) + node, err := signaling.BuildAcceptStanza( + ctx, + socket, + callID, + secret.callKey, + secret.peer, + secret.creator, + secret.video, + ) + if err != nil { + return fmt.Errorf("build call accept: %w", err) + } + if err := socket.SendNode(ctx, node); err != nil { + return fmt.Errorf("send call accept: %w", err) + } + return nil +} + +func (s *session) store(callID string, secret *callSecret) { + if callID == "" || secret == nil { + return + } + s.mu.Lock() + if previous := s.secrets[callID]; previous != nil { + zeroBytes(previous.callKey) + } + s.secrets[callID] = secret + s.mu.Unlock() +} + +func (s *session) copySecret(callID string) (*callSecret, bool) { + s.mu.RLock() + secret, ok := s.secrets[callID] + if !ok || secret == nil { + s.mu.RUnlock() + return nil, false + } + copyValue := &callSecret{ + callKey: append([]byte(nil), secret.callKey...), + peer: secret.peer, + creator: secret.creator, + video: secret.video, + } + s.mu.RUnlock() + return copyValue, true +} + +func (s *session) remove(callID string) { + s.mu.Lock() + if secret := s.secrets[callID]; secret != nil { + zeroBytes(secret.callKey) + } + delete(s.secrets, callID) + s.mu.Unlock() +} + +func (s *session) clear() { + s.mu.Lock() + for callID, secret := range s.secrets { + if secret != nil { + zeroBytes(secret.callKey) + } + delete(s.secrets, callID) + } + s.mu.Unlock() +} + +func (s *session) close() { + s.mu.Lock() + client := s.client + handlerID := s.handlerID + s.client = nil + s.handlerID = 0 + s.mu.Unlock() + + if client != nil && handlerID != 0 { + client.RemoveEventHandler(handlerID) + } + s.clear() +} + +func zeroBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} + +// Registry stores one private incoming-call session per Evolution instance. +type Registry struct { + mu sync.RWMutex + sessions map[string]*session +} + +func NewRegistry() *Registry { + return &Registry{sessions: make(map[string]*session)} +} + +// Attach installs an isolated call handler on the same authenticated client used +// by messaging. Reattaching the same pointer is idempotent. +func (r *Registry) Attach(instanceID string, client *whatsmeow.Client) { + if instanceID == "" || client == nil { + return + } + + r.mu.RLock() + current := r.sessions[instanceID] + if current != nil && current.client == client { + r.mu.RUnlock() + return + } + r.mu.RUnlock() + + candidate := newSession(client) + + r.mu.Lock() + previous := r.sessions[instanceID] + r.sessions[instanceID] = candidate + r.mu.Unlock() + + if previous != nil { + previous.close() + } +} + +func (r *Registry) Accept(ctx context.Context, instanceID, callID string) error { + r.mu.RLock() + s := r.sessions[instanceID] + r.mu.RUnlock() + if s == nil { + return fmt.Errorf("incoming call runtime is not attached for instance %s", instanceID) + } + return s.accept(ctx, callID) +} + +func (r *Registry) Remove(instanceID, callID string) { + r.mu.RLock() + s := r.sessions[instanceID] + r.mu.RUnlock() + if s != nil { + s.remove(callID) + } +} + +func (r *Registry) Close(instanceID string) { + r.mu.Lock() + s := r.sessions[instanceID] + delete(r.sessions, instanceID) + r.mu.Unlock() + if s != nil { + s.close() + } +} From 27bad215fb61a4f2875a89a1599335ff6ce16e44 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:08:13 -0300 Subject: [PATCH 027/266] feat(call): parse incoming offer media and encrypted key node --- pkg/call/voip/signaling/parse.go | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 pkg/call/voip/signaling/parse.go diff --git a/pkg/call/voip/signaling/parse.go b/pkg/call/voip/signaling/parse.go new file mode 100644 index 00000000..a29d2f25 --- /dev/null +++ b/pkg/call/voip/signaling/parse.go @@ -0,0 +1,52 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package signaling + +import ( + "strings" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode" + waBinary "go.mau.fi/whatsmeow/binary" +) + +// NodeContainsVideo reports whether a call protocol node advertises video. +func NodeContainsVideo(node *waBinary.Node) bool { + if node == nil { + return false + } + if strings.EqualFold(node.Tag, "video") { + return true + } + for key, value := range node.Attrs { + keyLower := strings.ToLower(key) + valueString := strings.ToLower(strings.TrimSpace(wanode.AttrString(node.Attrs, key))) + if (keyLower == "media" || keyLower == "type") && valueString == "video" { + return true + } + } + for index := range wanode.NodeChildren(node) { + children := wanode.NodeChildren(node) + if NodeContainsVideo(&children[index]) { + return true + } + } + return false +} + +func findEncryptedCallKeyNode(inner *waBinary.Node) *waBinary.Node { + if inner == nil { + return nil + } + for _, childValue := range wanode.NodeChildren(inner) { + child := childValue + if child.Tag == "enc" && wanode.HasAttr(child.Attrs, "type") { + return &child + } + } + for _, childValue := range wanode.NodeChildren(inner) { + child := childValue + if found := findEncryptedCallKeyNode(&child); found != nil { + return found + } + } + return nil +} From baaa915dfa8169746bb4a1139186dcad3fdfe8d4 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:08:25 -0300 Subject: [PATCH 028/266] feat(call): decrypt incoming WhatsApp call keys --- pkg/call/voip/signaling/callkey.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/call/voip/signaling/callkey.go b/pkg/call/voip/signaling/callkey.go index 90d1b072..bfc1de6c 100644 --- a/pkg/call/voip/signaling/callkey.go +++ b/pkg/call/voip/signaling/callkey.go @@ -3,12 +3,16 @@ package signaling import ( + "context" "crypto/rand" "encoding/hex" "fmt" "strings" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" "google.golang.org/protobuf/proto" ) @@ -48,3 +52,20 @@ func DecodeCallKeyPlaintext(plaintext []byte) ([]byte, error) { } return key, nil } + +// DecryptCallKeyInNode finds the encrypted call key in an incoming offer and +// decrypts it through the currently authenticated whatsmeow Signal session. +func DecryptCallKeyInNode(ctx context.Context, socket core.VoipSocket, inner *waBinary.Node, peer types.JID) ([]byte, error) { + encrypted := findEncryptedCallKeyNode(inner) + if encrypted == nil { + return nil, fmt.Errorf("incoming call offer does not contain an encrypted call key") + } + key, err := socket.DecryptCallKey(ctx, peer, encrypted) + if err != nil { + return nil, fmt.Errorf("decrypt incoming call key: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("invalid decrypted call key length: %d", len(key)) + } + return key, nil +} From 5ba7a6cc0a3873e85c4b6e29344f87f159d48069 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:08:37 -0300 Subject: [PATCH 029/266] fix(call): compile signaling node parser --- pkg/call/voip/signaling/parse.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/pkg/call/voip/signaling/parse.go b/pkg/call/voip/signaling/parse.go index a29d2f25..6a45f9e3 100644 --- a/pkg/call/voip/signaling/parse.go +++ b/pkg/call/voip/signaling/parse.go @@ -16,15 +16,15 @@ func NodeContainsVideo(node *waBinary.Node) bool { if strings.EqualFold(node.Tag, "video") { return true } - for key, value := range node.Attrs { + for key := range node.Attrs { keyLower := strings.ToLower(key) valueString := strings.ToLower(strings.TrimSpace(wanode.AttrString(node.Attrs, key))) if (keyLower == "media" || keyLower == "type") && valueString == "video" { return true } } - for index := range wanode.NodeChildren(node) { - children := wanode.NodeChildren(node) + children := wanode.NodeChildren(node) + for index := range children { if NodeContainsVideo(&children[index]) { return true } @@ -36,15 +36,14 @@ func findEncryptedCallKeyNode(inner *waBinary.Node) *waBinary.Node { if inner == nil { return nil } - for _, childValue := range wanode.NodeChildren(inner) { - child := childValue - if child.Tag == "enc" && wanode.HasAttr(child.Attrs, "type") { - return &child + children := wanode.NodeChildren(inner) + for index := range children { + if children[index].Tag == "enc" && wanode.HasAttr(children[index].Attrs, "type") { + return &children[index] } } - for _, childValue := range wanode.NodeChildren(inner) { - child := childValue - if found := findEncryptedCallKeyNode(&child); found != nil { + for index := range children { + if found := findEncryptedCallKeyNode(&children[index]); found != nil { return found } } From fcd33daaab052ddacf185c8d555fd8bdb607a3da Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:09:05 -0300 Subject: [PATCH 030/266] feat(call): build preaccept and accept stanzas --- pkg/call/voip/signaling/build.go | 102 ++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/pkg/call/voip/signaling/build.go b/pkg/call/voip/signaling/build.go index 3acd80de..2043caf8 100644 --- a/pkg/call/voip/signaling/build.go +++ b/pkg/call/voip/signaling/build.go @@ -11,7 +11,10 @@ import ( "go.mau.fi/whatsmeow/types" ) -var capabilityOffer = []byte{0x01, 0x05, 0xf7, 0x09, 0xe4, 0xbb, 0x07} +var ( + capabilityOffer = []byte{0x01, 0x05, 0xf7, 0x09, 0xe4, 0xbb, 0x07} + capabilityPreaccept = []byte{0x01, 0x05, 0xff, 0x09, 0xe4, 0xbb, 0x07} +) func BuildOfferStanza(ctx context.Context, socket core.VoipSocket, callID string, callKey []byte, peer types.JID, video bool) (waBinary.Node, error) { creator := socket.OwnLID() @@ -85,6 +88,103 @@ func BuildOfferStanza(ctx context.Context, socket core.VoipSocket, callID string }, nil } +// BuildPreacceptStanza acknowledges an incoming offer while the local user is +// deciding whether to accept it. +func BuildPreacceptStanza(peer types.JID, callID string, creator types.JID) waBinary.Node { + return waBinary.Node{ + Tag: "call", + Attrs: waBinary.Attrs{"to": peer, "id": GenerateCallStanzaID()}, + Content: []waBinary.Node{{ + Tag: "preaccept", + Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator}, + Content: []waBinary.Node{ + {Tag: "audio", Attrs: waBinary.Attrs{"enc": "opus", "rate": "16000"}}, + {Tag: "encopt", Attrs: waBinary.Attrs{"keygen": "2"}}, + {Tag: "capability", Attrs: waBinary.Attrs{"ver": "1"}, Content: capabilityPreaccept}, + }, + }}, + } +} + +// BuildAcceptStanza encrypts the incoming call key back to the initiating +// device and constructs the explicit call acceptance node. +func BuildAcceptStanza( + ctx context.Context, + socket core.VoipSocket, + callID string, + callKey []byte, + peer types.JID, + creator types.JID, + video bool, +) (waBinary.Node, error) { + if len(callKey) != 32 { + return waBinary.Node{}, fmt.Errorf("invalid call key length: %d", len(callKey)) + } + if peer.IsEmpty() || creator.IsEmpty() { + return waBinary.Node{}, fmt.Errorf("peer and call creator are required") + } + if err := socket.AssertSessions(ctx, []types.JID{creator}, true); err != nil { + return waBinary.Node{}, fmt.Errorf("assert creator session: %w", err) + } + + participants, includeIdentity, err := socket.CreateParticipantNodes( + ctx, + []types.JID{creator}, + callKey, + waBinary.Attrs{"count": "0"}, + ) + if err != nil { + return waBinary.Node{}, fmt.Errorf("encrypt accept key: %w", err) + } + encrypted := extractEncryptedNode(participants) + if encrypted == nil { + return waBinary.Node{}, fmt.Errorf("participant encryption did not produce an enc node") + } + + content := []waBinary.Node{ + {Tag: "audio", Attrs: waBinary.Attrs{"enc": "opus", "rate": "16000"}}, + {Tag: "net", Attrs: waBinary.Attrs{"medium": "3"}}, + *encrypted, + {Tag: "encopt", Attrs: waBinary.Attrs{"keygen": "2"}}, + } + if includeIdentity { + if identity, ok := socket.AccountDeviceIdentityNode(); ok { + content = append(content, identity) + } + } + if video { + content = append(content, waBinary.Node{Tag: "video", Attrs: waBinary.Attrs{"enc": "vp8"}}) + } + + return waBinary.Node{ + Tag: "call", + Attrs: waBinary.Attrs{ + "to": wanode.MustJID(wanode.CleanJID(peer.String())), + "id": GenerateCallStanzaID(), + }, + Content: []waBinary.Node{{ + Tag: "accept", + Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator}, + Content: content, + }}, + }, nil +} + +func extractEncryptedNode(nodes []waBinary.Node) *waBinary.Node { + for index := range nodes { + if nodes[index].Tag == "enc" { + return &nodes[index] + } + children := nodes[index].GetChildren() + for childIndex := range children { + if children[childIndex].Tag == "enc" { + return &children[childIndex] + } + } + } + return nil +} + func BuildTerminateStanza(peer types.JID, callID string, creator types.JID) waBinary.Node { return wrap(peer, waBinary.Node{ Tag: "terminate", From 138ca2c67d195caa6eb57f9b6c4ee19c27a88310 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:09:35 -0300 Subject: [PATCH 031/266] feat(call): accept prepared incoming calls --- pkg/call/service/call_service.go | 40 ++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/pkg/call/service/call_service.go b/pkg/call/service/call_service.go index 29f5405a..fef0503f 100644 --- a/pkg/call/service/call_service.go +++ b/pkg/call/service/call_service.go @@ -8,6 +8,7 @@ import ( call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" call_driver "github.com/evolution-foundation/evolution-go/pkg/call/voip/driver" + call_incoming "github.com/evolution-foundation/evolution-go/pkg/call/voip/incoming" instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" logger_wrapper "github.com/evolution-foundation/evolution-go/pkg/logger" "github.com/evolution-foundation/evolution-go/pkg/utils" @@ -21,6 +22,7 @@ const signalingTimeout = 30 * time.Second type CallService interface { StartCall(data *StartCallStruct, instance *instance_model.Instance) (call_runtime.Call, error) + AcceptCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) TerminateCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) RejectCall(data *RejectCallStruct, instance *instance_model.Instance) error RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) @@ -31,6 +33,7 @@ type callService struct { whatsmeowService whatsmeow_service.WhatsmeowService loggerWrapper *logger_wrapper.LoggerManager runtimeRegistry *call_runtime.Registry + incomingRegistry *call_incoming.Registry } type StartCallStruct struct { @@ -77,9 +80,10 @@ func (c *callService) ensureClientConnected(instanceID string) (*whatsmeow.Clien return nil, errors.New("client disconnected") } - // Calls and messaging share the same authenticated client. Attach also - // installs the isolated call event handler and replaces it after reconnects. + // Calls and messaging share the same authenticated client. The public runtime + // tracks state while the private incoming registry holds non-serializable keys. c.runtimeRegistry.Attach(instanceID, client) + c.incomingRegistry.Attach(instanceID, client) c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Client successfully validated - Connected: %v", instanceID, client.IsConnected()) return client, nil @@ -124,6 +128,36 @@ func (c *callService) StartCall(data *StartCallStruct, instance *instance_model. return call, nil } +func (c *callService) AcceptCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) { + client, err := c.ensureClientConnected(instance.Id) + if err != nil { + return call_runtime.Call{}, err + } + + runtime := c.runtimeRegistry.Attach(instance.Id, client) + call, ok := runtime.Call(callID) + if !ok { + return call_runtime.Call{}, fmt.Errorf("call %s not found", callID) + } + if call.Direction != call_runtime.DirectionIncoming { + return call_runtime.Call{}, fmt.Errorf("call %s is not incoming", callID) + } + if call.State == call_runtime.StateEnded || call.State == call_runtime.StateFailed { + return call_runtime.Call{}, fmt.Errorf("call %s cannot be accepted in state %s", callID, call.State) + } + + ctx, cancel := context.WithTimeout(context.Background(), signalingTimeout) + defer cancel() + if err := c.incomingRegistry.Accept(ctx, instance.Id, callID); err != nil { + return call_runtime.Call{}, err + } + + runtime.Transition(callID, "", call_runtime.DirectionIncoming, call_runtime.StateConnecting, nil, "") + call, _ = runtime.Call(callID) + c.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Incoming call accepted - CallID: %s", instance.Id, callID) + return call, nil +} + func (c *callService) TerminateCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) { client, err := c.ensureClientConnected(instance.Id) if err != nil { @@ -169,6 +203,7 @@ func (c *callService) RejectCall(data *RejectCallStruct, instance *instance_mode return err } + c.incomingRegistry.Remove(instance.Id, data.CallID) runtime := c.runtimeRegistry.Attach(instance.Id, client) runtime.Transition(data.CallID, data.CallCreator.String(), call_runtime.DirectionIncoming, call_runtime.StateEnded, nil, "rejected") return nil @@ -194,5 +229,6 @@ func NewCallService( whatsmeowService: whatsmeowService, loggerWrapper: loggerWrapper, runtimeRegistry: call_runtime.NewRegistry(), + incomingRegistry: call_incoming.NewRegistry(), } } From 00037ea7b6f69d09e613e36fa885e515bc0355bd Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:10:01 -0300 Subject: [PATCH 032/266] feat(call): expose incoming call accept handler --- pkg/call/handler/call_handler.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pkg/call/handler/call_handler.go b/pkg/call/handler/call_handler.go index 3d9a6d37..84ca578a 100644 --- a/pkg/call/handler/call_handler.go +++ b/pkg/call/handler/call_handler.go @@ -10,6 +10,7 @@ import ( type CallHandler interface { StartCall(ctx *gin.Context) + AcceptCall(ctx *gin.Context) TerminateCall(ctx *gin.Context) RejectCall(ctx *gin.Context) Status(ctx *gin.Context) @@ -64,6 +65,35 @@ func (g *callHandler) StartCall(ctx *gin.Context) { ctx.JSON(http.StatusCreated, call) } +// Accept call +// @Summary Accept an incoming WhatsApp call +// @Description Sends preaccept and accept signaling for a prepared incoming call. Audio transport is not implemented yet. +// @Tags Call +// @Produce json +// @Param callId path string true "Call ID" +// @Success 200 {object} gin.H "Call accepted" +// @Failure 400 {object} gin.H "Invalid request" +// @Failure 500 {object} gin.H "Call signaling failed" +// @Router /call/{callId}/accept [post] +func (g *callHandler) AcceptCall(ctx *gin.Context) { + instance, ok := instanceFromContext(ctx) + if !ok { + return + } + callID := ctx.Param("callId") + if callID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "callId is required"}) + return + } + + call, err := g.callService.AcceptCall(callID, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, call) +} + // Terminate call // @Summary Terminate an outgoing WhatsApp call // @Description Sends a terminate stanza for an outgoing call tracked by this instance From 39a607796c2c9b77a284061f78db74152d8c57b7 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:10:50 -0300 Subject: [PATCH 033/266] test(call): cover incoming call signaling --- pkg/call/voip/signaling/build_test.go | 102 ++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 5 deletions(-) diff --git a/pkg/call/voip/signaling/build_test.go b/pkg/call/voip/signaling/build_test.go index fa4cd9e7..079423eb 100644 --- a/pkg/call/voip/signaling/build_test.go +++ b/pkg/call/voip/signaling/build_test.go @@ -10,8 +10,9 @@ import ( ) type fakeSocket struct { - own types.JID - devices []types.JID + own types.JID + devices []types.JID + decryptedKey []byte } func (f *fakeSocket) OwnPN() types.JID { return f.own } @@ -27,11 +28,23 @@ func (f *fakeSocket) GetUSyncDevices(context.Context, []types.JID) ([]types.JID, return f.devices, nil } func (f *fakeSocket) AssertSessions(context.Context, []types.JID, bool) error { return nil } -func (f *fakeSocket) CreateParticipantNodes(context.Context, []types.JID, []byte, waBinary.Attrs) ([]waBinary.Node, bool, error) { - return []waBinary.Node{{Tag: "to", Attrs: waBinary.Attrs{"jid": f.devices[0]}}}, true, nil +func (f *fakeSocket) CreateParticipantNodes(_ context.Context, devices []types.JID, _ []byte, _ waBinary.Attrs) ([]waBinary.Node, bool, error) { + device := types.JID{} + if len(devices) > 0 { + device = devices[0] + } + return []waBinary.Node{{ + Tag: "to", + Attrs: waBinary.Attrs{"jid": device}, + Content: []waBinary.Node{{ + Tag: "enc", + Attrs: waBinary.Attrs{"type": "msg"}, + Content: []byte{1, 2, 3}, + }}, + }}, true, nil } func (f *fakeSocket) DecryptCallKey(context.Context, types.JID, *waBinary.Node) ([]byte, error) { - return nil, nil + return append([]byte(nil), f.decryptedKey...), nil } func (f *fakeSocket) GetTCToken(context.Context, types.JID) ([]byte, error) { return nil, nil } func (f *fakeSocket) ResolveLIDForPN(_ context.Context, jid types.JID) types.JID { return jid } @@ -100,3 +113,82 @@ func TestBuildOfferRequiresDevices(t *testing.T) { t.Fatal("BuildOfferStanza() expected error when no peer devices are available") } } + +func TestBuildPreacceptStanza(t *testing.T) { + peer := types.NewJID("5511999999999", types.DefaultUserServer) + creator := types.NewJID("5511999999999", types.HiddenUserServer) + node := BuildPreacceptStanza(peer, "CALL-IN", creator) + children := wanode.NodeChildren(&node) + if len(children) != 1 || children[0].Tag != "preaccept" { + t.Fatalf("unexpected preaccept node: %#v", children) + } + if wanode.AttrString(children[0].Attrs, "call-id") != "CALL-IN" { + t.Fatalf("unexpected call id: %s", wanode.AttrString(children[0].Attrs, "call-id")) + } +} + +func TestBuildAcceptStanza(t *testing.T) { + own := types.NewJID("5511000000000", types.DefaultUserServer) + peer := types.NewJID("5511999999999", types.DefaultUserServer) + creator := types.NewJID("5511999999999", types.HiddenUserServer) + socket := &fakeSocket{own: own, devices: []types.JID{creator}} + + node, err := BuildAcceptStanza(context.Background(), socket, "CALL-IN", make([]byte, 32), peer, creator, true) + if err != nil { + t.Fatalf("BuildAcceptStanza() error = %v", err) + } + children := wanode.NodeChildren(&node) + if len(children) != 1 || children[0].Tag != "accept" { + t.Fatalf("unexpected accept node: %#v", children) + } + var encrypted, video bool + for _, child := range wanode.NodeChildren(&children[0]) { + if child.Tag == "enc" { + encrypted = true + } + if child.Tag == "video" { + video = true + } + } + if !encrypted || !video { + t.Fatalf("accept missing nodes: encrypted=%v video=%v", encrypted, video) + } +} + +func TestDecryptCallKeyInNode(t *testing.T) { + peer := types.NewJID("5511999999999", types.DefaultUserServer) + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + socket := &fakeSocket{decryptedKey: key} + offer := &waBinary.Node{ + Tag: "offer", + Content: []waBinary.Node{{ + Tag: "destination", + Content: []waBinary.Node{{ + Tag: "to", + Content: []waBinary.Node{{ + Tag: "enc", + Attrs: waBinary.Attrs{"type": "msg"}, + Content: []byte{9}, + }}, + }}, + }}, + } + + decrypted, err := DecryptCallKeyInNode(context.Background(), socket, offer, peer) + if err != nil { + t.Fatalf("DecryptCallKeyInNode() error = %v", err) + } + if len(decrypted) != 32 || decrypted[31] != 32 { + t.Fatalf("unexpected decrypted key: %v", decrypted) + } +} + +func TestNodeContainsVideo(t *testing.T) { + offer := &waBinary.Node{Tag: "offer", Content: []waBinary.Node{{Tag: "video"}}} + if !NodeContainsVideo(offer) { + t.Fatal("expected video offer to be detected") + } +} From a5ea8ef8598cb91eaeadf81e992dd85a0286a77b Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:11:37 -0300 Subject: [PATCH 034/266] feat(call): register incoming call accept route --- pkg/routes/routes.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index dd63418f..036fbfa9 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -195,6 +195,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { { routes.GET("/status", r.callHandler.Status) routes.POST("/start", r.callHandler.StartCall) + routes.POST("/:callId/accept", r.callHandler.AcceptCall) routes.DELETE("/:callId", r.callHandler.TerminateCall) routes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall) } From adefce42ada50d40d9a39b0d6b45e24a8726bf64 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:11:53 -0300 Subject: [PATCH 035/266] test(call): verify private call key lifecycle --- pkg/call/voip/incoming/registry_test.go | 68 +++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 pkg/call/voip/incoming/registry_test.go diff --git a/pkg/call/voip/incoming/registry_test.go b/pkg/call/voip/incoming/registry_test.go new file mode 100644 index 00000000..d7efd946 --- /dev/null +++ b/pkg/call/voip/incoming/registry_test.go @@ -0,0 +1,68 @@ +package incoming + +import ( + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestSecretCopyIsIndependent(t *testing.T) { + s := &session{secrets: make(map[string]*callSecret)} + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + s.store("call-1", &callSecret{ + callKey: key, + peer: types.NewJID("5511999999999", types.DefaultUserServer), + creator: types.NewJID("5511999999999", types.HiddenUserServer), + }) + + copyValue, ok := s.copySecret("call-1") + if !ok { + t.Fatal("expected secret copy") + } + copyValue.callKey[0] = 99 + stored, _ := s.copySecret("call-1") + if stored.callKey[0] != 1 { + t.Fatal("mutating a secret copy changed the stored key") + } + zeroBytes(copyValue.callKey) + zeroBytes(stored.callKey) +} + +func TestRemoveZeroesStoredKey(t *testing.T) { + s := &session{secrets: make(map[string]*callSecret)} + key := make([]byte, 32) + for index := range key { + key[index] = 7 + } + s.store("call-1", &callSecret{callKey: key}) + s.remove("call-1") + + if _, ok := s.copySecret("call-1"); ok { + t.Fatal("secret was not removed") + } + for index, value := range key { + if value != 0 { + t.Fatalf("key byte %d was not zeroed: %d", index, value) + } + } +} + +func TestClearZeroesAllKeys(t *testing.T) { + s := &session{secrets: make(map[string]*callSecret)} + first := []byte{1, 2, 3} + second := []byte{4, 5, 6} + s.store("first", &callSecret{callKey: first}) + s.store("second", &callSecret{callKey: second}) + s.clear() + + for _, key := range [][]byte{first, second} { + for _, value := range key { + if value != 0 { + t.Fatalf("key was not zeroed: %v", key) + } + } + } +} From 11d0d42a13cbc692e2d8abdde00316d405e56296 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:12:32 -0300 Subject: [PATCH 036/266] docs(call): document incoming call acceptance --- docs/wiki/guias-api/api-calls-experimental.md | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md index 1c7e114d..f410b2ca 100644 --- a/docs/wiki/guias-api/api-calls-experimental.md +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -2,7 +2,7 @@ Esta branch adiciona a primeira etapa da integração WaCalls/AstraCalls ao Evolution Go. -> **Estado atual:** a sinalização é real e pode fazer o aparelho remoto tocar, mas o transporte de áudio/WebRTC/SRTP ainda não foi conectado. Não use em produção. +> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. O transporte de áudio/WebRTC/SRTP ainda não foi conectado. Não use em produção. Todas as rotas usam a autenticação normal da instância do Evolution. @@ -12,7 +12,7 @@ Todas as rotas usam a autenticação normal da instância do Evolution. GET /call/status ``` -Além de retornar as chamadas conhecidas, essa rota anexa o monitor de eventos ao `whatsmeow.Client` da instância. Nesta etapa experimental, execute-a ao menos uma vez após conectar ou reconectar a instância para monitorar chamadas recebidas antes de qualquer operação de chamada. +Além de retornar as chamadas conhecidas, essa rota anexa os monitores de eventos e de material criptográfico ao `whatsmeow.Client` da instância. Nesta etapa experimental, execute-a ao menos uma vez após conectar ou reconectar a instância para monitorar chamadas recebidas. Exemplo de resposta: @@ -24,6 +24,8 @@ Exemplo de resposta: } ``` +Chaves de chamada, JIDs internos de dispositivos e outros dados privados não fazem parte dessa resposta. Eles ficam somente em memória e são zerados quando a chamada termina, é rejeitada ou a sessão é desconectada. + ## Iniciar uma chamada ```http @@ -53,6 +55,29 @@ A resposta HTTP `201` contém o `id` da chamada e o estado inicial `ringing`. } ``` +## Aceitar uma chamada recebida + +Quando uma chamada recebida aparecer em `GET /call/status`, use: + +```http +POST /call/{callId}/accept +apikey: INSTANCE_TOKEN +``` + +O runtime descriptografa a chave recebida usando a sessão Signal já autenticada, envia `preaccept` automaticamente e mantém o material somente na memória privada. O endpoint envia a stanza `accept` e retorna a chamada no estado `connecting`. + +```json +{ + "id": "CALL_ID", + "peer": "5511999999999@s.whatsapp.net", + "direction": "incoming", + "state": "connecting", + "video": false +} +``` + +Se a preparação criptográfica do evento ainda não terminou, a API retorna um erro informando que a chamada ainda não está pronta para aceite. + ## Encerrar uma chamada realizada ```http @@ -94,6 +119,7 @@ O runtime escuta `CallOffer`, `CallOfferNotice`, `CallPreAccept`, `CallAccept`, - sem áudio bidirecional; - sem WebRTC para navegador; - sem SRTP/relay do WhatsApp; -- aceite de chamadas recebidas ainda não exposto; +- aceitar a sinalização não estabelece o caminho de mídia; +- encerramento via API de chamadas recebidas ainda depende do `CallManager` completo; - o runtime ainda precisa ser ativado por uma rota de chamadas após reconexão; - API e formatos podem mudar enquanto o PR estiver em rascunho. From 3e9e07bf884c6896c7c54fd062a673db75268a65 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:13:53 -0300 Subject: [PATCH 037/266] feat(call): terminate incoming calls and harden private session races --- pkg/call/voip/incoming/registry.go | 45 +++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/pkg/call/voip/incoming/registry.go b/pkg/call/voip/incoming/registry.go index 0d8478bb..1a82f95a 100644 --- a/pkg/call/voip/incoming/registry.go +++ b/pkg/call/voip/incoming/registry.go @@ -43,6 +43,12 @@ func newSession(client *whatsmeow.Client) *session { return s } +func (s *session) usesClient(client *whatsmeow.Client) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.client == client && client != nil +} + func (s *session) handleEvent(rawEvent interface{}) { switch event := rawEvent.(type) { case *events.CallOffer: @@ -92,6 +98,10 @@ func (s *session) prepareOffer(event *events.CallOffer) { if err != nil || len(callKey) != 32 { return } + if !s.usesClient(client) { + zeroBytes(callKey) + return + } secret := &callSecret{ callKey: append([]byte(nil), callKey...), @@ -99,6 +109,7 @@ func (s *session) prepareOffer(event *events.CallOffer) { creator: creator, video: signaling.NodeContainsVideo(event.Data), } + zeroBytes(callKey) s.store(event.CallID, secret) // WhatsApp expects preaccept before the user explicitly accepts the call. @@ -140,6 +151,28 @@ func (s *session) accept(ctx context.Context, callID string) error { return nil } +func (s *session) terminate(ctx context.Context, callID string) error { + secret, ok := s.copySecret(callID) + if !ok { + return fmt.Errorf("incoming call %s has no private signaling material", callID) + } + defer zeroBytes(secret.callKey) + + s.mu.RLock() + client := s.client + s.mu.RUnlock() + if client == nil { + return fmt.Errorf("incoming call session is detached") + } + + node := signaling.BuildTerminateStanza(secret.peer, callID, secret.creator) + if err := wa.NewSocket(client).SendNode(ctx, node); err != nil { + return fmt.Errorf("send incoming call terminate: %w", err) + } + s.remove(callID) + return nil +} + func (s *session) store(callID string, secret *callSecret) { if callID == "" || secret == nil { return @@ -228,7 +261,7 @@ func (r *Registry) Attach(instanceID string, client *whatsmeow.Client) { r.mu.RLock() current := r.sessions[instanceID] - if current != nil && current.client == client { + if current != nil && current.usesClient(client) { r.mu.RUnlock() return } @@ -256,6 +289,16 @@ func (r *Registry) Accept(ctx context.Context, instanceID, callID string) error return s.accept(ctx, callID) } +func (r *Registry) Terminate(ctx context.Context, instanceID, callID string) error { + r.mu.RLock() + s := r.sessions[instanceID] + r.mu.RUnlock() + if s == nil { + return fmt.Errorf("incoming call runtime is not attached for instance %s", instanceID) + } + return s.terminate(ctx, callID) +} + func (r *Registry) Remove(instanceID, callID string) { r.mu.RLock() s := r.sessions[instanceID] From bf39b954498bb8ac2eca8ddbb89ad297cbb78d79 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:14:20 -0300 Subject: [PATCH 038/266] feat(call): terminate accepted incoming calls --- pkg/call/service/call_service.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/pkg/call/service/call_service.go b/pkg/call/service/call_service.go index fef0503f..575bd807 100644 --- a/pkg/call/service/call_service.go +++ b/pkg/call/service/call_service.go @@ -172,19 +172,22 @@ func (c *callService) TerminateCall(callID string, instance *instance_model.Inst if call.State == call_runtime.StateEnded || call.State == call_runtime.StateFailed { return call, nil } - if call.Direction != call_runtime.DirectionOutgoing { - return call_runtime.Call{}, fmt.Errorf("terminating incoming calls will be enabled with the full CallManager port") - } - - peer, err := types.ParseJID(call.Peer) - if err != nil || peer.IsEmpty() { - return call_runtime.Call{}, fmt.Errorf("invalid call peer: %s", call.Peer) - } ctx, cancel := context.WithTimeout(context.Background(), signalingTimeout) defer cancel() - if err := call_driver.NewSignalingDriver(client).EndOutgoing(ctx, callID, peer); err != nil { - return call_runtime.Call{}, err + + if call.Direction == call_runtime.DirectionIncoming { + if err := c.incomingRegistry.Terminate(ctx, instance.Id, callID); err != nil { + return call_runtime.Call{}, err + } + } else { + peer, parseErr := types.ParseJID(call.Peer) + if parseErr != nil || peer.IsEmpty() { + return call_runtime.Call{}, fmt.Errorf("invalid call peer: %s", call.Peer) + } + if err := call_driver.NewSignalingDriver(client).EndOutgoing(ctx, callID, peer); err != nil { + return call_runtime.Call{}, err + } } runtime.Transition(callID, "", "", call_runtime.StateEnded, nil, "user_ended") From c15a65e2f3c45913c13454d629d5d71c5cee7115 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:15:21 -0300 Subject: [PATCH 039/266] docs(call): document incoming call termination --- docs/wiki/guias-api/api-calls-experimental.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md index f410b2ca..36f03c70 100644 --- a/docs/wiki/guias-api/api-calls-experimental.md +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -78,14 +78,14 @@ O runtime descriptografa a chave recebida usando a sessão Signal já autenticad Se a preparação criptográfica do evento ainda não terminou, a API retorna um erro informando que a chamada ainda não está pronta para aceite. -## Encerrar uma chamada realizada +## Encerrar uma chamada ```http DELETE /call/{callId} apikey: INSTANCE_TOKEN ``` -Nesta etapa, o encerramento por essa rota está habilitado apenas para chamadas de saída registradas pelo runtime. +A rota envia `terminate` tanto para chamadas realizadas quanto para chamadas recebidas cujo material privado ainda está disponível em memória. Em seguida, o runtime muda o estado para `ended` e apaga a chave da chamada recebida. ## Rejeitar uma chamada recebida @@ -120,6 +120,6 @@ O runtime escuta `CallOffer`, `CallOfferNotice`, `CallPreAccept`, `CallAccept`, - sem WebRTC para navegador; - sem SRTP/relay do WhatsApp; - aceitar a sinalização não estabelece o caminho de mídia; -- encerramento via API de chamadas recebidas ainda depende do `CallManager` completo; +- as chaves ficam somente em memória e não sobrevivem a reinícios; - o runtime ainda precisa ser ativado por uma rota de chamadas após reconexão; - API e formatos podem mudar enquanto o PR estiver em rascunho. From 6a391f8a8d222492612f601ec6b01770cba29eb7 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:52:30 -0300 Subject: [PATCH 040/266] feat(call): add shared client lifecycle coordinator --- pkg/call/lifecycle/coordinator.go | 73 +++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 pkg/call/lifecycle/coordinator.go diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go new file mode 100644 index 00000000..903dd5ad --- /dev/null +++ b/pkg/call/lifecycle/coordinator.go @@ -0,0 +1,73 @@ +// Package lifecycle coordinates call state and private incoming-call material +// for each Evolution WhatsApp client. +package lifecycle + +import ( + "context" + + call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" + call_incoming "github.com/evolution-foundation/evolution-go/pkg/call/voip/incoming" + "go.mau.fi/whatsmeow" +) + +// Coordinator owns the call registries shared by the WhatsApp lifecycle and +// the HTTP call service. It is safe for concurrent use. +type Coordinator struct { + runtimes *call_runtime.Registry + incoming *call_incoming.Registry +} + +func NewCoordinator() *Coordinator { + return &Coordinator{ + runtimes: call_runtime.NewRegistry(), + incoming: call_incoming.NewRegistry(), + } +} + +// Attach installs both call event handlers on the authenticated client. The +// operation is idempotent for the same instance/client pair and replaces old +// handlers when whatsmeow creates a new client during reconnect. +func (c *Coordinator) Attach(instanceID string, client *whatsmeow.Client) { + if c == nil || instanceID == "" || client == nil { + return + } + c.runtimes.Attach(instanceID, client) + c.incoming.Attach(instanceID, client) +} + +// Detach removes handlers and erases private call keys for an instance. +func (c *Coordinator) Detach(instanceID string) { + if c == nil || instanceID == "" { + return + } + c.runtimes.Remove(instanceID) + c.incoming.Close(instanceID) +} + +func (c *Coordinator) Runtime(instanceID string) (*call_runtime.Runtime, bool) { + if c == nil { + return nil, false + } + return c.runtimes.Get(instanceID) +} + +func (c *Coordinator) RuntimeFor(instanceID string, client *whatsmeow.Client) *call_runtime.Runtime { + if c == nil || instanceID == "" { + return nil + } + c.Attach(instanceID, client) + runtime, _ := c.runtimes.Get(instanceID) + return runtime +} + +func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID string) error { + return c.incoming.Accept(ctx, instanceID, callID) +} + +func (c *Coordinator) TerminateIncoming(ctx context.Context, instanceID, callID string) error { + return c.incoming.Terminate(ctx, instanceID, callID) +} + +func (c *Coordinator) RemoveIncoming(instanceID, callID string) { + c.incoming.Remove(instanceID, callID) +} From c051859aab9fbfc2962fa255a85d9d58f6cdcfc2 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:53:21 -0300 Subject: [PATCH 041/266] fix(call): preserve automatic call rejection lifecycle --- pkg/call/lifecycle/coordinator.go | 58 +++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go index 903dd5ad..966a66fa 100644 --- a/pkg/call/lifecycle/coordinator.go +++ b/pkg/call/lifecycle/coordinator.go @@ -4,6 +4,7 @@ package lifecycle import ( "context" + "sync" call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" call_incoming "github.com/evolution-foundation/evolution-go/pkg/call/voip/incoming" @@ -13,37 +14,72 @@ import ( // Coordinator owns the call registries shared by the WhatsApp lifecycle and // the HTTP call service. It is safe for concurrent use. type Coordinator struct { - runtimes *call_runtime.Registry - incoming *call_incoming.Registry + mu sync.RWMutex + runtimes *call_runtime.Registry + incoming *call_incoming.Registry + incomingEnabled map[string]bool } func NewCoordinator() *Coordinator { return &Coordinator{ - runtimes: call_runtime.NewRegistry(), - incoming: call_incoming.NewRegistry(), + runtimes: call_runtime.NewRegistry(), + incoming: call_incoming.NewRegistry(), + incomingEnabled: make(map[string]bool), } } -// Attach installs both call event handlers on the authenticated client. The -// operation is idempotent for the same instance/client pair and replaces old -// handlers when whatsmeow creates a new client during reconnect. -func (c *Coordinator) Attach(instanceID string, client *whatsmeow.Client) { +// AttachClient is called by the WhatsApp client lifecycle. Public call state is +// always monitored, while private offer preparation is disabled for instances +// configured to reject incoming calls automatically. +func (c *Coordinator) AttachClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool) { if c == nil || instanceID == "" || client == nil { return } + + c.mu.Lock() + c.incomingEnabled[instanceID] = prepareIncoming + c.mu.Unlock() + c.runtimes.Attach(instanceID, client) - c.incoming.Attach(instanceID, client) + if prepareIncoming { + c.incoming.Attach(instanceID, client) + } else { + c.incoming.Close(instanceID) + } } -// Detach removes handlers and erases private call keys for an instance. -func (c *Coordinator) Detach(instanceID string) { +// DetachClient removes handlers, configuration and private call keys. +func (c *Coordinator) DetachClient(instanceID string) { if c == nil || instanceID == "" { return } + c.mu.Lock() + delete(c.incomingEnabled, instanceID) + c.mu.Unlock() c.runtimes.Remove(instanceID) c.incoming.Close(instanceID) } +// Attach keeps call-service operations idempotent without overriding the +// automatic-rejection policy configured by AttachClient. +func (c *Coordinator) Attach(instanceID string, client *whatsmeow.Client) { + if c == nil || instanceID == "" || client == nil { + return + } + c.runtimes.Attach(instanceID, client) + + c.mu.RLock() + prepareIncoming, configured := c.incomingEnabled[instanceID] + c.mu.RUnlock() + if !configured || prepareIncoming { + c.incoming.Attach(instanceID, client) + } +} + +func (c *Coordinator) Detach(instanceID string) { + c.DetachClient(instanceID) +} + func (c *Coordinator) Runtime(instanceID string) (*call_runtime.Runtime, bool) { if c == nil { return nil, false From ae50831b1d5d9a0fcf0bf4d50dc85c634a3c8308 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:53:50 -0300 Subject: [PATCH 042/266] chore(call): stage lifecycle hook migration --- tools/apply_call_lifecycle.py | 180 ++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tools/apply_call_lifecycle.py diff --git a/tools/apply_call_lifecycle.py b/tools/apply_call_lifecycle.py new file mode 100644 index 00000000..518c3869 --- /dev/null +++ b/tools/apply_call_lifecycle.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def replace_once(path: Path, old: str, new: str) -> None: + text = path.read_text() + count = text.count(old) + if count != 1: + raise RuntimeError(f"{path}: expected one match, found {count}: {old[:80]!r}") + path.write_text(text.replace(old, new, 1)) + + +whatsmeow = Path("pkg/whatsmeow/service/whatsmeow.go") +replace_once( + whatsmeow, + "type WhatsmeowService interface {\n", + "type ClientLifecycle interface {\n" + "\tAttachClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool)\n" + "\tDetachClient(instanceID string)\n" + "}\n\n" + "type WhatsmeowService interface {\n" + "\tSetClientLifecycle(lifecycle ClientLifecycle)\n", +) +replace_once( + whatsmeow, + "\tpasskeyCeremony *ceremony.Store\n}", + "\tpasskeyCeremony *ceremony.Store\n" + "\tclientLifecycle ClientLifecycle\n}", +) +replace_once( + whatsmeow, + "func (w whatsmeowService) ReconnectClient(instanceId string) error {\n", + "func (w whatsmeowService) ReconnectClient(instanceId string) error {\n" + "\tw.detachCallClient(instanceId)\n", +) +replace_once( + whatsmeow, + "\tif w.clientPointer[cd.Instance.Id] != nil {\n" + "\t\tif w.clientPointer[cd.Instance.Id].IsConnected() {\n" + "\t\t\treturn\n" + "\t\t}\n" + "\t}\n", + "\tif existing := w.clientPointer[cd.Instance.Id]; existing != nil {\n" + "\t\tif existing.IsConnected() {\n" + "\t\t\treturn\n" + "\t\t}\n" + "\t\tw.detachCallClient(cd.Instance.Id)\n" + "\t}\n", +) +replace_once( + whatsmeow, + "\t// Armazena o MyClient no map para permitir atualizações posteriores\n" + "\tw.myClientPointer[cd.Instance.Id] = mycli\n", + "\t// Armazena o MyClient no map para permitir atualizações posteriores\n" + "\tw.myClientPointer[cd.Instance.Id] = mycli\n\n" + "\t// Call monitoring starts with the WhatsApp client itself, before the\n" + "\t// connection can emit an incoming offer. Auto-reject instances keep only\n" + "\t// the public state tracker and do not decrypt/send preaccept.\n" + "\tw.attachCallClient(cd.Instance.Id, client, !cd.Instance.RejectCall)\n", +) +replace_once( + whatsmeow, + "func (w whatsmeowService) ClearInstanceCache(instanceId string, token string) error {\n", + "func (w whatsmeowService) ClearInstanceCache(instanceId string, token string) error {\n" + "\tw.detachCallClient(instanceId)\n", +) + +Path("pkg/whatsmeow/service/call_lifecycle.go").write_text( + '''package whatsmeow_service + +import "go.mau.fi/whatsmeow" + +// SetClientLifecycle injects the call coordinator without coupling the +// WhatsApp service package to the call implementation. +func (w *whatsmeowService) SetClientLifecycle(lifecycle ClientLifecycle) { +\tw.clientLifecycle = lifecycle +} + +func (w whatsmeowService) attachCallClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool) { +\tif w.clientLifecycle != nil { +\t\tw.clientLifecycle.AttachClient(instanceID, client, prepareIncoming) +\t} +} + +func (w whatsmeowService) detachCallClient(instanceID string) { +\tif w.clientLifecycle != nil { +\t\tw.clientLifecycle.DetachClient(instanceID) +\t} +} +''' +) + +call_service = Path("pkg/call/service/call_service.go") +replace_once( + call_service, + "\tcall_runtime \"github.com/evolution-foundation/evolution-go/pkg/call/runtime\"\n" + "\tcall_driver \"github.com/evolution-foundation/evolution-go/pkg/call/voip/driver\"\n" + "\tcall_incoming \"github.com/evolution-foundation/evolution-go/pkg/call/voip/incoming\"\n", + "\tcall_lifecycle \"github.com/evolution-foundation/evolution-go/pkg/call/lifecycle\"\n" + "\tcall_runtime \"github.com/evolution-foundation/evolution-go/pkg/call/runtime\"\n" + "\tcall_driver \"github.com/evolution-foundation/evolution-go/pkg/call/voip/driver\"\n", +) +replace_once( + call_service, + "\tloggerWrapper *logger_wrapper.LoggerManager\n" + "\truntimeRegistry *call_runtime.Registry\n" + "\tincomingRegistry *call_incoming.Registry\n", + "\tloggerWrapper *logger_wrapper.LoggerManager\n" + "\tcoordinator *call_lifecycle.Coordinator\n", +) +replace_once( + call_service, + "\tc.runtimeRegistry.Attach(instanceID, client)\n" + "\tc.incomingRegistry.Attach(instanceID, client)\n", + "\tc.coordinator.Attach(instanceID, client)\n", +) +call_text = call_service.read_text() +call_text = call_text.replace("c.runtimeRegistry.Attach(", "c.coordinator.RuntimeFor(") +call_text = call_text.replace("c.incomingRegistry.Accept(", "c.coordinator.AcceptIncoming(") +call_text = call_text.replace("c.incomingRegistry.Terminate(", "c.coordinator.TerminateIncoming(") +call_text = call_text.replace("c.incomingRegistry.Remove(", "c.coordinator.RemoveIncoming(") +call_service.write_text(call_text) +replace_once( + call_service, + "func NewCallService(\n" + "\tclientPointer map[string]*whatsmeow.Client,\n" + "\twhatsmeowService whatsmeow_service.WhatsmeowService,\n" + "\tloggerWrapper *logger_wrapper.LoggerManager,\n" + ") CallService {\n" + "\treturn &callService{\n" + "\t\tclientPointer: clientPointer,\n" + "\t\twhatsmeowService: whatsmeowService,\n" + "\t\tloggerWrapper: loggerWrapper,\n" + "\t\truntimeRegistry: call_runtime.NewRegistry(),\n" + "\t\tincomingRegistry: call_incoming.NewRegistry(),\n" + "\t}\n" + "}\n", + "func NewCallService(\n" + "\tclientPointer map[string]*whatsmeow.Client,\n" + "\twhatsmeowService whatsmeow_service.WhatsmeowService,\n" + "\tloggerWrapper *logger_wrapper.LoggerManager,\n" + "\tcoordinator *call_lifecycle.Coordinator,\n" + ") CallService {\n" + "\tif coordinator == nil {\n" + "\t\tcoordinator = call_lifecycle.NewCoordinator()\n" + "\t}\n" + "\treturn &callService{\n" + "\t\tclientPointer: clientPointer,\n" + "\t\twhatsmeowService: whatsmeowService,\n" + "\t\tloggerWrapper: loggerWrapper,\n" + "\t\tcoordinator: coordinator,\n" + "\t}\n" + "}\n", +) + +main = Path("cmd/evolution-go/main.go") +replace_once( + main, + "\tcall_handler \"github.com/evolution-foundation/evolution-go/pkg/call/handler\"\n" + "\tcall_service \"github.com/evolution-foundation/evolution-go/pkg/call/service\"\n", + "\tcall_handler \"github.com/evolution-foundation/evolution-go/pkg/call/handler\"\n" + "\tcall_lifecycle \"github.com/evolution-foundation/evolution-go/pkg/call/lifecycle\"\n" + "\tcall_service \"github.com/evolution-foundation/evolution-go/pkg/call/service\"\n", +) +replace_once( + main, + "\tinstanceService := instance_service.NewInstanceService(\n", + "\tcallCoordinator := call_lifecycle.NewCoordinator()\n" + "\twhatsmeowService.SetClientLifecycle(callCoordinator)\n\n" + "\tinstanceService := instance_service.NewInstanceService(\n", +) +replace_once( + main, + "\tcallService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper)\n", + "\tcallService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper, callCoordinator)\n", +) + +# Remove the one-shot migration machinery from the resulting commit. +Path("tools/apply_call_lifecycle.py").unlink() +Path(".github/workflows/apply-call-lifecycle.yml").unlink() From 7c465c8495a56e804d7826d9de0f9fcfbfe3e4c2 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:54:02 -0300 Subject: [PATCH 043/266] chore(call): apply lifecycle hooks atomically --- .github/workflows/apply-call-lifecycle.yml | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/apply-call-lifecycle.yml diff --git a/.github/workflows/apply-call-lifecycle.yml b/.github/workflows/apply-call-lifecycle.yml new file mode 100644 index 00000000..dcbed111 --- /dev/null +++ b/.github/workflows/apply-call-lifecycle.yml @@ -0,0 +1,50 @@ +name: Apply call lifecycle hooks + +on: + push: + branches: + - dev/astracalls-integration + paths: + - .github/workflows/apply-call-lifecycle.yml + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@v4 + with: + ref: dev/astracalls-integration + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Apply lifecycle migration + run: python3 tools/apply_call_lifecycle.py + + - name: Format modified Go files + run: | + gofmt -w \ + cmd/evolution-go/main.go \ + pkg/call/service/call_service.go \ + pkg/call/lifecycle/coordinator.go \ + pkg/whatsmeow/service/call_lifecycle.go \ + pkg/whatsmeow/service/whatsmeow.go + + - name: Validate call packages + run: go test -race ./pkg/call/... + + - name: Commit migration + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "feat(call): attach call lifecycle to WhatsApp clients" + git push origin HEAD:dev/astracalls-integration From 63925a338f85c1d05307be8d529d3cb996d428b7 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:55:05 -0300 Subject: [PATCH 044/266] chore(call): run lifecycle migration from pull request --- .github/workflows/apply-call-lifecycle.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/apply-call-lifecycle.yml b/.github/workflows/apply-call-lifecycle.yml index dcbed111..3d1b8cf3 100644 --- a/.github/workflows/apply-call-lifecycle.yml +++ b/.github/workflows/apply-call-lifecycle.yml @@ -1,11 +1,13 @@ name: Apply call lifecycle hooks on: - push: + pull_request: branches: - - dev/astracalls-integration + - main paths: - .github/workflows/apply-call-lifecycle.yml + - tools/apply_call_lifecycle.py + - pkg/call/lifecycle/coordinator.go permissions: contents: write From 82fc9c674d55780988796eb8e5a9d6a9f396ca51 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:56:16 -0300 Subject: [PATCH 045/266] fix(call): target lifecycle field insertion precisely --- tools/apply_call_lifecycle.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tools/apply_call_lifecycle.py b/tools/apply_call_lifecycle.py index 518c3869..6aac9595 100644 --- a/tools/apply_call_lifecycle.py +++ b/tools/apply_call_lifecycle.py @@ -6,7 +6,7 @@ def replace_once(path: Path, old: str, new: str) -> None: text = path.read_text() count = text.count(old) if count != 1: - raise RuntimeError(f"{path}: expected one match, found {count}: {old[:80]!r}") + raise RuntimeError(f"{path}: expected one match, found {count}: {old[:100]!r}") path.write_text(text.replace(old, new, 1)) @@ -23,9 +23,13 @@ def replace_once(path: Path, old: str, new: str) -> None: ) replace_once( whatsmeow, - "\tpasskeyCeremony *ceremony.Store\n}", + "\tloggerWrapper *logger_wrapper.LoggerManager\n" "\tpasskeyCeremony *ceremony.Store\n" - "\tclientLifecycle ClientLifecycle\n}", + "}", + "\tloggerWrapper *logger_wrapper.LoggerManager\n" + "\tpasskeyCeremony *ceremony.Store\n" + "\tclientLifecycle ClientLifecycle\n" + "}", ) replace_once( whatsmeow, @@ -175,6 +179,5 @@ def replace_once(path: Path, old: str, new: str) -> None: "\tcallService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper, callCoordinator)\n", ) -# Remove the one-shot migration machinery from the resulting commit. Path("tools/apply_call_lifecycle.py").unlink() Path(".github/workflows/apply-call-lifecycle.yml").unlink() From 730cc243c06130d71eb02a9c8e7bc41ab5c4ec79 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:56:58 +0000 Subject: [PATCH 046/266] feat(call): attach call lifecycle to WhatsApp clients --- .github/workflows/apply-call-lifecycle.yml | 52 ------ cmd/evolution-go/main.go | 6 +- pkg/call/service/call_service.go | 31 ++-- pkg/whatsmeow/service/call_lifecycle.go | 21 +++ pkg/whatsmeow/service/whatsmeow.go | 19 ++- tools/apply_call_lifecycle.py | 183 --------------------- 6 files changed, 59 insertions(+), 253 deletions(-) delete mode 100644 .github/workflows/apply-call-lifecycle.yml create mode 100644 pkg/whatsmeow/service/call_lifecycle.go delete mode 100644 tools/apply_call_lifecycle.py diff --git a/.github/workflows/apply-call-lifecycle.yml b/.github/workflows/apply-call-lifecycle.yml deleted file mode 100644 index 3d1b8cf3..00000000 --- a/.github/workflows/apply-call-lifecycle.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Apply call lifecycle hooks - -on: - pull_request: - branches: - - main - paths: - - .github/workflows/apply-call-lifecycle.yml - - tools/apply_call_lifecycle.py - - pkg/call/lifecycle/coordinator.go - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@v4 - with: - ref: dev/astracalls-integration - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Apply lifecycle migration - run: python3 tools/apply_call_lifecycle.py - - - name: Format modified Go files - run: | - gofmt -w \ - cmd/evolution-go/main.go \ - pkg/call/service/call_service.go \ - pkg/call/lifecycle/coordinator.go \ - pkg/whatsmeow/service/call_lifecycle.go \ - pkg/whatsmeow/service/whatsmeow.go - - - name: Validate call packages - run: go test -race ./pkg/call/... - - - name: Commit migration - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "feat(call): attach call lifecycle to WhatsApp clients" - git push origin HEAD:dev/astracalls-integration diff --git a/cmd/evolution-go/main.go b/cmd/evolution-go/main.go index 5234583f..efcc97fe 100644 --- a/cmd/evolution-go/main.go +++ b/cmd/evolution-go/main.go @@ -22,6 +22,7 @@ import ( _ "modernc.org/sqlite" call_handler "github.com/evolution-foundation/evolution-go/pkg/call/handler" + call_lifecycle "github.com/evolution-foundation/evolution-go/pkg/call/lifecycle" call_service "github.com/evolution-foundation/evolution-go/pkg/call/service" chat_handler "github.com/evolution-foundation/evolution-go/pkg/chat/handler" chat_service "github.com/evolution-foundation/evolution-go/pkg/chat/service" @@ -179,6 +180,9 @@ func setupRouter(db *gorm.DB, authDB *sql.DB, sqliteDB *sql.DB, config *config.C natsProducer, loggerWrapper, ) + callCoordinator := call_lifecycle.NewCoordinator() + whatsmeowService.SetClientLifecycle(callCoordinator) + instanceService := instance_service.NewInstanceService( instanceRepository, killChannel, @@ -192,7 +196,7 @@ func setupRouter(db *gorm.DB, authDB *sql.DB, sqliteDB *sql.DB, config *config.C messageService := message_service.NewMessageService(clientPointer, messageRepository, whatsmeowService, loggerWrapper) chatService := chat_service.NewChatService(clientPointer, whatsmeowService, loggerWrapper) groupService := group_service.NewGroupService(clientPointer, whatsmeowService, loggerWrapper) - callService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper) + callService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper, callCoordinator) communityService := community_service.NewCommunityService(clientPointer, whatsmeowService, loggerWrapper) labelService := label_service.NewLabelService(clientPointer, whatsmeowService, labelRepository, loggerWrapper) newsletterService := newsletter_service.NewNewsletterService(clientPointer, whatsmeowService, loggerWrapper) diff --git a/pkg/call/service/call_service.go b/pkg/call/service/call_service.go index 575bd807..db669c61 100644 --- a/pkg/call/service/call_service.go +++ b/pkg/call/service/call_service.go @@ -6,9 +6,9 @@ import ( "fmt" "time" + call_lifecycle "github.com/evolution-foundation/evolution-go/pkg/call/lifecycle" call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" call_driver "github.com/evolution-foundation/evolution-go/pkg/call/voip/driver" - call_incoming "github.com/evolution-foundation/evolution-go/pkg/call/voip/incoming" instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" logger_wrapper "github.com/evolution-foundation/evolution-go/pkg/logger" "github.com/evolution-foundation/evolution-go/pkg/utils" @@ -32,8 +32,7 @@ type callService struct { clientPointer map[string]*whatsmeow.Client whatsmeowService whatsmeow_service.WhatsmeowService loggerWrapper *logger_wrapper.LoggerManager - runtimeRegistry *call_runtime.Registry - incomingRegistry *call_incoming.Registry + coordinator *call_lifecycle.Coordinator } type StartCallStruct struct { @@ -82,8 +81,7 @@ func (c *callService) ensureClientConnected(instanceID string) (*whatsmeow.Clien // Calls and messaging share the same authenticated client. The public runtime // tracks state while the private incoming registry holds non-serializable keys. - c.runtimeRegistry.Attach(instanceID, client) - c.incomingRegistry.Attach(instanceID, client) + c.coordinator.Attach(instanceID, client) c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Client successfully validated - Connected: %v", instanceID, client.IsConnected()) return client, nil @@ -113,7 +111,7 @@ func (c *callService) StartCall(data *StartCallStruct, instance *instance_model. return call_runtime.Call{}, err } - runtime := c.runtimeRegistry.Attach(instance.Id, client) + runtime := c.coordinator.RuntimeFor(instance.Id, client) video := data.Video runtime.Transition( callID, @@ -134,7 +132,7 @@ func (c *callService) AcceptCall(callID string, instance *instance_model.Instanc return call_runtime.Call{}, err } - runtime := c.runtimeRegistry.Attach(instance.Id, client) + runtime := c.coordinator.RuntimeFor(instance.Id, client) call, ok := runtime.Call(callID) if !ok { return call_runtime.Call{}, fmt.Errorf("call %s not found", callID) @@ -148,7 +146,7 @@ func (c *callService) AcceptCall(callID string, instance *instance_model.Instanc ctx, cancel := context.WithTimeout(context.Background(), signalingTimeout) defer cancel() - if err := c.incomingRegistry.Accept(ctx, instance.Id, callID); err != nil { + if err := c.coordinator.AcceptIncoming(ctx, instance.Id, callID); err != nil { return call_runtime.Call{}, err } @@ -164,7 +162,7 @@ func (c *callService) TerminateCall(callID string, instance *instance_model.Inst return call_runtime.Call{}, err } - runtime := c.runtimeRegistry.Attach(instance.Id, client) + runtime := c.coordinator.RuntimeFor(instance.Id, client) call, ok := runtime.Call(callID) if !ok { return call_runtime.Call{}, fmt.Errorf("call %s not found", callID) @@ -177,7 +175,7 @@ func (c *callService) TerminateCall(callID string, instance *instance_model.Inst defer cancel() if call.Direction == call_runtime.DirectionIncoming { - if err := c.incomingRegistry.Terminate(ctx, instance.Id, callID); err != nil { + if err := c.coordinator.TerminateIncoming(ctx, instance.Id, callID); err != nil { return call_runtime.Call{}, err } } else { @@ -206,8 +204,8 @@ func (c *callService) RejectCall(data *RejectCallStruct, instance *instance_mode return err } - c.incomingRegistry.Remove(instance.Id, data.CallID) - runtime := c.runtimeRegistry.Attach(instance.Id, client) + c.coordinator.RemoveIncoming(instance.Id, data.CallID) + runtime := c.coordinator.RuntimeFor(instance.Id, client) runtime.Transition(data.CallID, data.CallCreator.String(), call_runtime.DirectionIncoming, call_runtime.StateEnded, nil, "rejected") return nil } @@ -218,7 +216,7 @@ func (c *callService) RuntimeStatus(instance *instance_model.Instance) (call_run return call_runtime.Snapshot{InstanceID: instance.Id}, err } - runtime := c.runtimeRegistry.Attach(instance.Id, client) + runtime := c.coordinator.RuntimeFor(instance.Id, client) return runtime.Snapshot(), nil } @@ -226,12 +224,15 @@ func NewCallService( clientPointer map[string]*whatsmeow.Client, whatsmeowService whatsmeow_service.WhatsmeowService, loggerWrapper *logger_wrapper.LoggerManager, + coordinator *call_lifecycle.Coordinator, ) CallService { + if coordinator == nil { + coordinator = call_lifecycle.NewCoordinator() + } return &callService{ clientPointer: clientPointer, whatsmeowService: whatsmeowService, loggerWrapper: loggerWrapper, - runtimeRegistry: call_runtime.NewRegistry(), - incomingRegistry: call_incoming.NewRegistry(), + coordinator: coordinator, } } diff --git a/pkg/whatsmeow/service/call_lifecycle.go b/pkg/whatsmeow/service/call_lifecycle.go new file mode 100644 index 00000000..05f981ef --- /dev/null +++ b/pkg/whatsmeow/service/call_lifecycle.go @@ -0,0 +1,21 @@ +package whatsmeow_service + +import "go.mau.fi/whatsmeow" + +// SetClientLifecycle injects the call coordinator without coupling the +// WhatsApp service package to the call implementation. +func (w *whatsmeowService) SetClientLifecycle(lifecycle ClientLifecycle) { + w.clientLifecycle = lifecycle +} + +func (w whatsmeowService) attachCallClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool) { + if w.clientLifecycle != nil { + w.clientLifecycle.AttachClient(instanceID, client, prepareIncoming) + } +} + +func (w whatsmeowService) detachCallClient(instanceID string) { + if w.clientLifecycle != nil { + w.clientLifecycle.DetachClient(instanceID) + } +} diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 366f0edb..f945d612 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -50,7 +50,13 @@ import ( "github.com/evolution-foundation/evolution-go/pkg/utils" ) +type ClientLifecycle interface { + AttachClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool) + DetachClient(instanceID string) +} + type WhatsmeowService interface { + SetClientLifecycle(lifecycle ClientLifecycle) StartClient(clientData *ClientData) ConnectOnStartup(clientName string) StartInstance(instanceId string) error @@ -97,6 +103,7 @@ type whatsmeowService struct { natsProducer producer_interfaces.Producer loggerWrapper *logger_wrapper.LoggerManager passkeyCeremony *ceremony.Store + clientLifecycle ClientLifecycle } type MyClient struct { @@ -172,6 +179,7 @@ type ProxyConfig struct { } func (w whatsmeowService) ReconnectClient(instanceId string) error { + w.detachCallClient(instanceId) w.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Starting reconnection process - simulating restart", instanceId) // Passo 1: Limpar conexão existente se houver @@ -308,10 +316,11 @@ func (w whatsmeowService) StartClient(cd *ClientData) { var deviceStore *store.Device var err error - if w.clientPointer[cd.Instance.Id] != nil { - if w.clientPointer[cd.Instance.Id].IsConnected() { + if existing := w.clientPointer[cd.Instance.Id]; existing != nil { + if existing.IsConnected() { return } + w.detachCallClient(cd.Instance.Id) } var container *sqlstore.Container @@ -502,6 +511,11 @@ func (w whatsmeowService) StartClient(cd *ClientData) { // Armazena o MyClient no map para permitir atualizações posteriores w.myClientPointer[cd.Instance.Id] = mycli + // Call monitoring starts with the WhatsApp client itself, before the + // connection can emit an incoming offer. Auto-reject instances keep only + // the public state tracker and do not decrypt/send preaccept. + w.attachCallClient(cd.Instance.Id, client, !cd.Instance.RejectCall) + if client.Store.ID != nil { w.loggerWrapper.GetLogger(cd.Instance.Id).LogInfo("[%s] Already logged in with JID: %s", cd.Instance.Id, client.Store.ID.String()) err = client.Connect() @@ -2756,6 +2770,7 @@ func (w whatsmeowService) UpdateInstanceAdvancedSettings(instanceId string) erro } func (w whatsmeowService) ClearInstanceCache(instanceId string, token string) error { + w.detachCallClient(instanceId) w.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Clearing instance cache - Token: %s", instanceId, token) // Limpar userInfoCache diff --git a/tools/apply_call_lifecycle.py b/tools/apply_call_lifecycle.py deleted file mode 100644 index 6aac9595..00000000 --- a/tools/apply_call_lifecycle.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - - -def replace_once(path: Path, old: str, new: str) -> None: - text = path.read_text() - count = text.count(old) - if count != 1: - raise RuntimeError(f"{path}: expected one match, found {count}: {old[:100]!r}") - path.write_text(text.replace(old, new, 1)) - - -whatsmeow = Path("pkg/whatsmeow/service/whatsmeow.go") -replace_once( - whatsmeow, - "type WhatsmeowService interface {\n", - "type ClientLifecycle interface {\n" - "\tAttachClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool)\n" - "\tDetachClient(instanceID string)\n" - "}\n\n" - "type WhatsmeowService interface {\n" - "\tSetClientLifecycle(lifecycle ClientLifecycle)\n", -) -replace_once( - whatsmeow, - "\tloggerWrapper *logger_wrapper.LoggerManager\n" - "\tpasskeyCeremony *ceremony.Store\n" - "}", - "\tloggerWrapper *logger_wrapper.LoggerManager\n" - "\tpasskeyCeremony *ceremony.Store\n" - "\tclientLifecycle ClientLifecycle\n" - "}", -) -replace_once( - whatsmeow, - "func (w whatsmeowService) ReconnectClient(instanceId string) error {\n", - "func (w whatsmeowService) ReconnectClient(instanceId string) error {\n" - "\tw.detachCallClient(instanceId)\n", -) -replace_once( - whatsmeow, - "\tif w.clientPointer[cd.Instance.Id] != nil {\n" - "\t\tif w.clientPointer[cd.Instance.Id].IsConnected() {\n" - "\t\t\treturn\n" - "\t\t}\n" - "\t}\n", - "\tif existing := w.clientPointer[cd.Instance.Id]; existing != nil {\n" - "\t\tif existing.IsConnected() {\n" - "\t\t\treturn\n" - "\t\t}\n" - "\t\tw.detachCallClient(cd.Instance.Id)\n" - "\t}\n", -) -replace_once( - whatsmeow, - "\t// Armazena o MyClient no map para permitir atualizações posteriores\n" - "\tw.myClientPointer[cd.Instance.Id] = mycli\n", - "\t// Armazena o MyClient no map para permitir atualizações posteriores\n" - "\tw.myClientPointer[cd.Instance.Id] = mycli\n\n" - "\t// Call monitoring starts with the WhatsApp client itself, before the\n" - "\t// connection can emit an incoming offer. Auto-reject instances keep only\n" - "\t// the public state tracker and do not decrypt/send preaccept.\n" - "\tw.attachCallClient(cd.Instance.Id, client, !cd.Instance.RejectCall)\n", -) -replace_once( - whatsmeow, - "func (w whatsmeowService) ClearInstanceCache(instanceId string, token string) error {\n", - "func (w whatsmeowService) ClearInstanceCache(instanceId string, token string) error {\n" - "\tw.detachCallClient(instanceId)\n", -) - -Path("pkg/whatsmeow/service/call_lifecycle.go").write_text( - '''package whatsmeow_service - -import "go.mau.fi/whatsmeow" - -// SetClientLifecycle injects the call coordinator without coupling the -// WhatsApp service package to the call implementation. -func (w *whatsmeowService) SetClientLifecycle(lifecycle ClientLifecycle) { -\tw.clientLifecycle = lifecycle -} - -func (w whatsmeowService) attachCallClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool) { -\tif w.clientLifecycle != nil { -\t\tw.clientLifecycle.AttachClient(instanceID, client, prepareIncoming) -\t} -} - -func (w whatsmeowService) detachCallClient(instanceID string) { -\tif w.clientLifecycle != nil { -\t\tw.clientLifecycle.DetachClient(instanceID) -\t} -} -''' -) - -call_service = Path("pkg/call/service/call_service.go") -replace_once( - call_service, - "\tcall_runtime \"github.com/evolution-foundation/evolution-go/pkg/call/runtime\"\n" - "\tcall_driver \"github.com/evolution-foundation/evolution-go/pkg/call/voip/driver\"\n" - "\tcall_incoming \"github.com/evolution-foundation/evolution-go/pkg/call/voip/incoming\"\n", - "\tcall_lifecycle \"github.com/evolution-foundation/evolution-go/pkg/call/lifecycle\"\n" - "\tcall_runtime \"github.com/evolution-foundation/evolution-go/pkg/call/runtime\"\n" - "\tcall_driver \"github.com/evolution-foundation/evolution-go/pkg/call/voip/driver\"\n", -) -replace_once( - call_service, - "\tloggerWrapper *logger_wrapper.LoggerManager\n" - "\truntimeRegistry *call_runtime.Registry\n" - "\tincomingRegistry *call_incoming.Registry\n", - "\tloggerWrapper *logger_wrapper.LoggerManager\n" - "\tcoordinator *call_lifecycle.Coordinator\n", -) -replace_once( - call_service, - "\tc.runtimeRegistry.Attach(instanceID, client)\n" - "\tc.incomingRegistry.Attach(instanceID, client)\n", - "\tc.coordinator.Attach(instanceID, client)\n", -) -call_text = call_service.read_text() -call_text = call_text.replace("c.runtimeRegistry.Attach(", "c.coordinator.RuntimeFor(") -call_text = call_text.replace("c.incomingRegistry.Accept(", "c.coordinator.AcceptIncoming(") -call_text = call_text.replace("c.incomingRegistry.Terminate(", "c.coordinator.TerminateIncoming(") -call_text = call_text.replace("c.incomingRegistry.Remove(", "c.coordinator.RemoveIncoming(") -call_service.write_text(call_text) -replace_once( - call_service, - "func NewCallService(\n" - "\tclientPointer map[string]*whatsmeow.Client,\n" - "\twhatsmeowService whatsmeow_service.WhatsmeowService,\n" - "\tloggerWrapper *logger_wrapper.LoggerManager,\n" - ") CallService {\n" - "\treturn &callService{\n" - "\t\tclientPointer: clientPointer,\n" - "\t\twhatsmeowService: whatsmeowService,\n" - "\t\tloggerWrapper: loggerWrapper,\n" - "\t\truntimeRegistry: call_runtime.NewRegistry(),\n" - "\t\tincomingRegistry: call_incoming.NewRegistry(),\n" - "\t}\n" - "}\n", - "func NewCallService(\n" - "\tclientPointer map[string]*whatsmeow.Client,\n" - "\twhatsmeowService whatsmeow_service.WhatsmeowService,\n" - "\tloggerWrapper *logger_wrapper.LoggerManager,\n" - "\tcoordinator *call_lifecycle.Coordinator,\n" - ") CallService {\n" - "\tif coordinator == nil {\n" - "\t\tcoordinator = call_lifecycle.NewCoordinator()\n" - "\t}\n" - "\treturn &callService{\n" - "\t\tclientPointer: clientPointer,\n" - "\t\twhatsmeowService: whatsmeowService,\n" - "\t\tloggerWrapper: loggerWrapper,\n" - "\t\tcoordinator: coordinator,\n" - "\t}\n" - "}\n", -) - -main = Path("cmd/evolution-go/main.go") -replace_once( - main, - "\tcall_handler \"github.com/evolution-foundation/evolution-go/pkg/call/handler\"\n" - "\tcall_service \"github.com/evolution-foundation/evolution-go/pkg/call/service\"\n", - "\tcall_handler \"github.com/evolution-foundation/evolution-go/pkg/call/handler\"\n" - "\tcall_lifecycle \"github.com/evolution-foundation/evolution-go/pkg/call/lifecycle\"\n" - "\tcall_service \"github.com/evolution-foundation/evolution-go/pkg/call/service\"\n", -) -replace_once( - main, - "\tinstanceService := instance_service.NewInstanceService(\n", - "\tcallCoordinator := call_lifecycle.NewCoordinator()\n" - "\twhatsmeowService.SetClientLifecycle(callCoordinator)\n\n" - "\tinstanceService := instance_service.NewInstanceService(\n", -) -replace_once( - main, - "\tcallService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper)\n", - "\tcallService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper, callCoordinator)\n", -) - -Path("tools/apply_call_lifecycle.py").unlink() -Path(".github/workflows/apply-call-lifecycle.yml").unlink() From cd6ca8c5cbb9bc5cb7b5abb9c24b25fcac61f439 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:57:29 -0300 Subject: [PATCH 047/266] feat(call): add WhatsApp relay domain types --- pkg/call/voip/core/relay.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 pkg/call/voip/core/relay.go diff --git a/pkg/call/voip/core/relay.go b/pkg/call/voip/core/relay.go new file mode 100644 index 00000000..06b5d33a --- /dev/null +++ b/pkg/call/voip/core/relay.go @@ -0,0 +1,35 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package core + +const WARelayPort = 3480 + +// RelayEndpoint describes one WhatsApp media relay candidate. Raw token fields +// are kept for the future SCTP transport while the base64 forms remain useful +// for diagnostics that do not expose the original byte slices by reference. +type RelayEndpoint struct { + IP string + Port int + Token string + AuthToken string + RawToken []byte + RawAuthToken []byte + Key string + RelayID int + Protocol int + C2RRtt *int + RelayName string + AddressBytes []byte + AuthTokenID string +} + +// RelayData is the transport metadata associated with a call. It intentionally +// remains outside public runtime snapshots until a redacted API representation +// is designed. +type RelayData struct { + Endpoints []RelayEndpoint + ParticipantJIDs []string + UUID string + SelfPID *int + PeerPID *int + HBHKey []byte +} From 86eb1683c2ba583eecb63404fd214957de210ee1 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:58:00 -0300 Subject: [PATCH 048/266] feat(call): parse WhatsApp relay metadata --- pkg/call/voip/signaling/relay.go | 256 +++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 pkg/call/voip/signaling/relay.go diff --git a/pkg/call/voip/signaling/relay.go b/pkg/call/voip/signaling/relay.go new file mode 100644 index 00000000..5b107e15 --- /dev/null +++ b/pkg/call/voip/signaling/relay.go @@ -0,0 +1,256 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package signaling + +import ( + "encoding/base64" + "sort" + "strconv" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode" + waBinary "go.mau.fi/whatsmeow/binary" +) + +// ParsedRelayAck contains the complete relay metadata returned in an offer ACK +// or embedded in an incoming offer using WhatsApp's structured te2 encoding. +type ParsedRelayAck struct { + Relays []core.RelayEndpoint + ParticipantJIDs []string + UUID string + SelfPID *int + PeerPID *int + HBHKey []byte +} + +// ExtractRelayEndpoints parses the older attribute-based relay form: +// . Some offers wrap candidates in a +// element, so both layouts are supported. +func ExtractRelayEndpoints(node *waBinary.Node) []core.RelayEndpoint { + var relays []core.RelayEndpoint + + parseRelay := func(candidate *waBinary.Node) { + ip := wanode.AttrString(candidate.Attrs, "ip") + token := wanode.AttrString(candidate.Attrs, "token") + if ip == "" || token == "" { + return + } + + key := firstAttr(candidate.Attrs, "relay-key", "relay_key", "key") + endpoint := core.RelayEndpoint{ + IP: ip, + Port: firstAttrInt(candidate.Attrs, core.WARelayPort, "port"), + Token: token, + AuthToken: firstAttr(candidate.Attrs, "auth-token", "auth_token"), + Key: key, + RelayID: firstAttrInt(candidate.Attrs, 0, "relay-id", "relay_id"), + Protocol: firstAttrInt(candidate.Attrs, 0, "protocol"), + RelayName: firstAttr(candidate.Attrs, "relay-name", "relay_name"), + } + if value, ok := firstOptionalInt(candidate.Attrs, "c2r-rtt", "c2r_rtt"); ok { + endpoint.C2RRtt = &value + } + relays = append(relays, endpoint) + } + + for _, childValue := range wanode.NodeChildren(node) { + child := childValue + switch child.Tag { + case "relay": + parseRelay(&child) + case "relays": + for _, relayValue := range wanode.NodeChildren(&child) { + relay := relayValue + if relay.Tag == "relay" { + parseRelay(&relay) + } + } + } + } + + sortRelaysByRTT(relays) + return relays +} + +// ParseRelayFromAck parses WhatsApp's structured relay response. Binary token +// material is copied so callers never retain slices backed by a protocol node. +func ParseRelayFromAck(node *waBinary.Node) ParsedRelayAck { + result := ParsedRelayAck{} + participantSeen := make(map[string]struct{}) + + addParticipant := func(jid string) { + if jid == "" { + return + } + if _, exists := participantSeen[jid]; exists { + return + } + participantSeen[jid] = struct{}{} + result.ParticipantJIDs = append(result.ParticipantJIDs, jid) + } + + for _, childValue := range wanode.NodeChildren(node) { + child := childValue + if child.Tag == "user" { + for _, deviceValue := range wanode.NodeChildren(&child) { + device := deviceValue + if device.Tag == "device" { + addParticipant(wanode.AttrString(device.Attrs, "jid")) + } + } + } + if child.Tag != "relay" { + continue + } + + result.UUID = wanode.AttrString(child.Attrs, "uuid") + if value, ok := firstOptionalInt(child.Attrs, "self_pid", "self-pid"); ok { + result.SelfPID = &value + } + if value, ok := firstOptionalInt(child.Attrs, "peer_pid", "peer-pid"); ok { + result.PeerPID = &value + } + + relayChildren := wanode.NodeChildren(&child) + for _, relayChildValue := range relayChildren { + relayChild := relayChildValue + if relayChild.Tag == "participant" { + addParticipant(wanode.AttrString(relayChild.Attrs, "jid")) + } + } + + var relayKey string + tokens := make(map[string]string) + authTokens := make(map[string]string) + rawTokens := make(map[string][]byte) + rawAuthTokens := make(map[string][]byte) + + for _, relayChildValue := range relayChildren { + relayChild := relayChildValue + switch relayChild.Tag { + case "key": + if value := wanode.NodeBytes(&relayChild); value != nil { + relayKey = string(value) + } + case "hbh_key": + result.HBHKey = decodeHBHKey(wanode.NodeBytes(&relayChild)) + case "token": + if value := wanode.NodeBytes(&relayChild); value != nil { + id := attrStringOr(relayChild.Attrs, "id", "0") + rawTokens[id] = append([]byte(nil), value...) + tokens[id] = base64.StdEncoding.EncodeToString(value) + } + case "auth_token": + if value := wanode.NodeBytes(&relayChild); value != nil { + id := attrStringOr(relayChild.Attrs, "id", "0") + rawAuthTokens[id] = append([]byte(nil), value...) + authTokens[id] = base64.StdEncoding.EncodeToString(value) + } + } + } + + for _, relayChildValue := range relayChildren { + relayChild := relayChildValue + if relayChild.Tag != "te2" { + continue + } + address := wanode.NodeBytes(&relayChild) + if len(address) != 6 { + continue + } + + tokenID := attrStringOr(relayChild.Attrs, "token_id", "0") + authTokenID := firstAttr(relayChild.Attrs, "auth_token_id", "auth-token-id") + endpoint := core.RelayEndpoint{ + IP: ipv4String(address[:4]), + Port: int(address[4])<<8 | int(address[5]), + Token: tokens[tokenID], + AuthToken: authTokens[authTokenID], + RawToken: append([]byte(nil), rawTokens[tokenID]...), + RawAuthToken: append([]byte(nil), rawAuthTokens[authTokenID]...), + Key: relayKey, + RelayID: firstAttrInt(relayChild.Attrs, 0, "relay_id", "relay-id"), + Protocol: firstAttrInt(relayChild.Attrs, 0, "protocol"), + RelayName: firstAttr(relayChild.Attrs, "relay_name", "relay-name"), + AddressBytes: append([]byte(nil), address...), + AuthTokenID: authTokenID, + } + if endpoint.AuthTokenID == "" { + endpoint.AuthTokenID = tokenID + } + if value, ok := firstOptionalInt(relayChild.Attrs, "c2r_rtt", "c2r-rtt"); ok { + endpoint.C2RRtt = &value + } + result.Relays = append(result.Relays, endpoint) + } + } + + sortRelaysByRTT(result.Relays) + return result +} + +func decodeHBHKey(value []byte) []byte { + if len(value) == 30 { + return append([]byte(nil), value...) + } + decoded, err := base64.StdEncoding.DecodeString(string(value)) + if err == nil && len(decoded) == 30 { + return append([]byte(nil), decoded...) + } + return nil +} + +func attrStringOr(attrs waBinary.Attrs, key, fallback string) string { + if value := wanode.AttrString(attrs, key); value != "" { + return value + } + return fallback +} + +func firstAttr(attrs waBinary.Attrs, keys ...string) string { + for _, key := range keys { + if value := wanode.AttrString(attrs, key); value != "" { + return value + } + } + return "" +} + +func firstAttrInt(attrs waBinary.Attrs, fallback int, keys ...string) int { + for _, key := range keys { + if wanode.HasAttr(attrs, key) { + return wanode.AttrInt(attrs, key, fallback) + } + } + return fallback +} + +func firstOptionalInt(attrs waBinary.Attrs, keys ...string) (int, bool) { + for _, key := range keys { + if wanode.HasAttr(attrs, key) { + return wanode.AttrInt(attrs, key, 0), true + } + } + return 0, false +} + +func ipv4String(value []byte) string { + return strconv.Itoa(int(value[0])) + "." + strconv.Itoa(int(value[1])) + "." + + strconv.Itoa(int(value[2])) + "." + strconv.Itoa(int(value[3])) +} + +func sortRelaysByRTT(relays []core.RelayEndpoint) { + sort.SliceStable(relays, func(left, right int) bool { + leftRTT := relays[left].C2RRtt + rightRTT := relays[right].C2RRtt + switch { + case leftRTT == nil && rightRTT == nil: + return false + case leftRTT == nil: + return false + case rightRTT == nil: + return true + default: + return *leftRTT < *rightRTT + } + }) +} From a97dd7ff811a4e57c020faa72c76d7c7d89d0f90 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:58:31 -0300 Subject: [PATCH 049/266] test(call): cover WhatsApp relay parsing --- pkg/call/voip/signaling/relay_test.go | 159 ++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 pkg/call/voip/signaling/relay_test.go diff --git a/pkg/call/voip/signaling/relay_test.go b/pkg/call/voip/signaling/relay_test.go new file mode 100644 index 00000000..b099eb60 --- /dev/null +++ b/pkg/call/voip/signaling/relay_test.go @@ -0,0 +1,159 @@ +package signaling + +import ( + "bytes" + "encoding/base64" + "testing" + + waBinary "go.mau.fi/whatsmeow/binary" +) + +func TestParseRelayFromAck(t *testing.T) { + token := []byte{0x01, 0x02, 0x03} + authToken := []byte{0x04, 0x05, 0x06} + hbhKey := bytes.Repeat([]byte{0x07}, 30) + + node := &waBinary.Node{ + Tag: "ack", + Content: []waBinary.Node{ + { + Tag: "user", + Content: []waBinary.Node{ + {Tag: "device", Attrs: waBinary.Attrs{"jid": "self:1@s.whatsapp.net"}}, + }, + }, + { + Tag: "relay", + Attrs: waBinary.Attrs{ + "uuid": "relay-uuid", + "self_pid": "11", + "peer_pid": "22", + }, + Content: []waBinary.Node{ + {Tag: "participant", Attrs: waBinary.Attrs{"jid": "self:1@s.whatsapp.net"}}, + {Tag: "participant", Attrs: waBinary.Attrs{"jid": "peer:2@s.whatsapp.net"}}, + {Tag: "key", Content: []byte("relay-key")}, + {Tag: "hbh_key", Content: hbhKey}, + {Tag: "token", Attrs: waBinary.Attrs{"id": "token-1"}, Content: token}, + {Tag: "auth_token", Attrs: waBinary.Attrs{"id": "auth-1"}, Content: authToken}, + { + Tag: "te2", + Attrs: waBinary.Attrs{ + "token_id": "token-1", + "auth_token_id": "auth-1", + "relay_id": "1", + "relay_name": "slow", + "protocol": "1", + "c2r_rtt": "40", + }, + Content: []byte{1, 2, 3, 4, 0x0d, 0x98}, + }, + { + Tag: "te2", + Attrs: waBinary.Attrs{ + "token_id": "token-1", + "auth_token_id": "auth-1", + "relay_id": "2", + "relay_name": "fast", + "protocol": "1", + "c2r_rtt": "10", + }, + Content: []byte{5, 6, 7, 8, 0x0d, 0x99}, + }, + {Tag: "te2", Content: []byte{1, 2, 3}}, + }, + }, + }, + } + + parsed := ParseRelayFromAck(node) + if parsed.UUID != "relay-uuid" { + t.Fatalf("UUID = %q", parsed.UUID) + } + if parsed.SelfPID == nil || *parsed.SelfPID != 11 { + t.Fatalf("SelfPID = %#v", parsed.SelfPID) + } + if parsed.PeerPID == nil || *parsed.PeerPID != 22 { + t.Fatalf("PeerPID = %#v", parsed.PeerPID) + } + if len(parsed.ParticipantJIDs) != 2 { + t.Fatalf("participants = %#v", parsed.ParticipantJIDs) + } + if len(parsed.Relays) != 2 { + t.Fatalf("relays = %#v", parsed.Relays) + } + + fast := parsed.Relays[0] + if fast.IP != "5.6.7.8" || fast.Port != 3481 || fast.RelayName != "fast" { + t.Fatalf("fast relay = %#v", fast) + } + if fast.C2RRtt == nil || *fast.C2RRtt != 10 { + t.Fatalf("fast relay RTT = %#v", fast.C2RRtt) + } + if fast.Token != base64.StdEncoding.EncodeToString(token) { + t.Fatalf("token = %q", fast.Token) + } + if fast.AuthToken != base64.StdEncoding.EncodeToString(authToken) { + t.Fatalf("auth token = %q", fast.AuthToken) + } + if fast.Key != "relay-key" || fast.AuthTokenID != "auth-1" { + t.Fatalf("relay credentials metadata = %#v", fast) + } + if !bytes.Equal(parsed.HBHKey, hbhKey) { + t.Fatalf("HBH key mismatch") + } + + // Parsed secrets must not alias protocol-node buffers. + token[0] = 0xff + authToken[0] = 0xff + hbhKey[0] = 0xff + if fast.RawToken[0] != 0x01 || fast.RawAuthToken[0] != 0x04 || parsed.HBHKey[0] != 0x07 { + t.Fatal("parsed relay material aliases input buffers") + } +} + +func TestExtractRelayEndpoints(t *testing.T) { + node := &waBinary.Node{ + Tag: "offer", + Content: []waBinary.Node{ + { + Tag: "relays", + Content: []waBinary.Node{ + {Tag: "relay", Attrs: waBinary.Attrs{ + "ip": "10.0.0.2", "port": "4000", "token": "slow-token", + "relay_key": "key-2", "relay_id": "2", "c2r_rtt": "30", + }}, + }, + }, + {Tag: "relay", Attrs: waBinary.Attrs{ + "ip": "10.0.0.1", "token": "fast-token", "relay-key": "key-1", + "relay-id": "1", "relay-name": "fast", "c2r-rtt": "5", + }}, + {Tag: "relay", Attrs: waBinary.Attrs{"ip": "10.0.0.3"}}, + }, + } + + relays := ExtractRelayEndpoints(node) + if len(relays) != 2 { + t.Fatalf("relay count = %d", len(relays)) + } + if relays[0].IP != "10.0.0.1" || relays[0].Port != 3480 || relays[0].RelayID != 1 { + t.Fatalf("first relay = %#v", relays[0]) + } + if relays[1].IP != "10.0.0.2" || relays[1].Port != 4000 || relays[1].Key != "key-2" { + t.Fatalf("second relay = %#v", relays[1]) + } +} + +func TestParseRelayFromAckDecodesBase64HBHKey(t *testing.T) { + expected := bytes.Repeat([]byte{0x42}, 30) + node := &waBinary.Node{Tag: "ack", Content: []waBinary.Node{{ + Tag: "relay", + Content: []waBinary.Node{{Tag: "hbh_key", Content: []byte(base64.StdEncoding.EncodeToString(expected))}}, + }}} + + parsed := ParseRelayFromAck(node) + if !bytes.Equal(parsed.HBHKey, expected) { + t.Fatalf("decoded HBH key = %x", parsed.HBHKey) + } +} From c6858aee210a715c552eae4f11a520a7d6510542 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:59:54 -0300 Subject: [PATCH 050/266] docs(call): document automatic lifecycle and relay parsing --- docs/wiki/guias-api/api-calls-experimental.md | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md index 36f03c70..74cab052 100644 --- a/docs/wiki/guias-api/api-calls-experimental.md +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -1,18 +1,18 @@ # API de chamadas — integração experimental -Esta branch adiciona a primeira etapa da integração WaCalls/AstraCalls ao Evolution Go. +Esta branch adiciona a integração experimental WaCalls/AstraCalls ao Evolution Go. -> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. O transporte de áudio/WebRTC/SRTP ainda não foi conectado. Não use em produção. +> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. Os metadados de relay já são interpretados, mas o transporte de áudio/WebRTC/SRTP ainda não foi conectado. Não use em produção. Todas as rotas usam a autenticação normal da instância do Evolution. -## Ativar e consultar o runtime +## Consultar o runtime ```http GET /call/status ``` -Além de retornar as chamadas conhecidas, essa rota anexa os monitores de eventos e de material criptográfico ao `whatsmeow.Client` da instância. Nesta etapa experimental, execute-a ao menos uma vez após conectar ou reconectar a instância para monitorar chamadas recebidas. +Os monitores de chamada agora são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, os handlers anteriores são removidos e o material privado é apagado antes que o novo cliente seja registrado. Não é mais necessário chamar `/call/status` para ativar o monitoramento. Exemplo de resposta: @@ -24,7 +24,9 @@ Exemplo de resposta: } ``` -Chaves de chamada, JIDs internos de dispositivos e outros dados privados não fazem parte dessa resposta. Eles ficam somente em memória e são zerados quando a chamada termina, é rejeitada ou a sessão é desconectada. +Chaves de chamada, JIDs internos de dispositivos, tokens de relay e outros dados privados não fazem parte dessa resposta. Eles ficam somente em memória e são apagados quando a chamada termina, é rejeitada ou a sessão é desconectada. + +Instâncias configuradas com rejeição automática continuam rastreando o estado público da chamada, mas não descriptografam a chave recebida nem enviam `preaccept`. ## Iniciar uma chamada @@ -114,12 +116,21 @@ apikey: INSTANCE_TOKEN O runtime escuta `CallOffer`, `CallOfferNotice`, `CallPreAccept`, `CallAccept`, `CallTransport`, `CallReject`, `CallTerminate`, `Disconnected` e `LoggedOut` no mesmo cliente utilizado pela mensageria. +## Relay já interpretado + +O módulo de sinalização reconhece os dois formatos encontrados nas respostas do WhatsApp: + +- candidatos com atributos diretos, como `ip`, `port`, `token`, `relay-id` e `c2r-rtt`; +- respostas estruturadas `te2`, com tokens binários, `auth_token`, participantes, UUID, PIDs, HBH key, protocolo e endereço codificado em seis bytes. + +Os candidatos são ordenados pelo menor RTT. Nesta etapa eles ainda não são usados para abrir a conexão SCTP. + ## Limitações atuais - sem áudio bidirecional; - sem WebRTC para navegador; -- sem SRTP/relay do WhatsApp; +- sem conexão SCTP com os relays; +- sem RTP ou SRTP; - aceitar a sinalização não estabelece o caminho de mídia; - as chaves ficam somente em memória e não sobrevivem a reinícios; -- o runtime ainda precisa ser ativado por uma rota de chamadas após reconexão; - API e formatos podem mudar enquanto o PR estiver em rascunho. From fa09079854a7c7f8bdc6ae051f157882031653ef Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:00:25 -0300 Subject: [PATCH 051/266] docs(call): clarify relay parser privacy --- docs/wiki/guias-api/api-calls-experimental.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md index 74cab052..82bbbc2c 100644 --- a/docs/wiki/guias-api/api-calls-experimental.md +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -123,7 +123,7 @@ O módulo de sinalização reconhece os dois formatos encontrados nas respostas - candidatos com atributos diretos, como `ip`, `port`, `token`, `relay-id` e `c2r-rtt`; - respostas estruturadas `te2`, com tokens binários, `auth_token`, participantes, UUID, PIDs, HBH key, protocolo e endereço codificado em seis bytes. -Os candidatos são ordenados pelo menor RTT. Nesta etapa eles ainda não são usados para abrir a conexão SCTP. +Os candidatos são ordenados pelo menor RTT. Os tokens binários são copiados para buffers próprios e esses dados não fazem parte dos snapshots públicos. Nesta etapa eles ainda não são usados para abrir a conexão SCTP. ## Limitações atuais From 3574565ae4332e0d6f9e4cb16e94337f7e88a850 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:40:45 -0300 Subject: [PATCH 052/266] feat(call): add private relay material lifecycle helpers --- pkg/call/voip/core/relay.go | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/pkg/call/voip/core/relay.go b/pkg/call/voip/core/relay.go index 06b5d33a..b39b0139 100644 --- a/pkg/call/voip/core/relay.go +++ b/pkg/call/voip/core/relay.go @@ -33,3 +33,77 @@ type RelayData struct { PeerPID *int HBHKey []byte } + +// CloneRelayData makes a defensive deep copy of all relay metadata. This keeps +// private call state independent from buffers owned by incoming protocol nodes. +func CloneRelayData(data *RelayData) *RelayData { + if data == nil { + return nil + } + + clone := &RelayData{ + ParticipantJIDs: append([]string(nil), data.ParticipantJIDs...), + UUID: data.UUID, + SelfPID: cloneInt(data.SelfPID), + PeerPID: cloneInt(data.PeerPID), + HBHKey: append([]byte(nil), data.HBHKey...), + } + clone.Endpoints = make([]RelayEndpoint, len(data.Endpoints)) + for index, endpoint := range data.Endpoints { + clone.Endpoints[index] = endpoint + clone.Endpoints[index].RawToken = append([]byte(nil), endpoint.RawToken...) + clone.Endpoints[index].RawAuthToken = append([]byte(nil), endpoint.RawAuthToken...) + clone.Endpoints[index].AddressBytes = append([]byte(nil), endpoint.AddressBytes...) + clone.Endpoints[index].C2RRtt = cloneInt(endpoint.C2RRtt) + } + return clone +} + +// ZeroRelayData overwrites byte material and clears references before private +// relay state is discarded. Strings cannot be overwritten in place in Go, so +// their references are dropped immediately. +func ZeroRelayData(data *RelayData) { + if data == nil { + return + } + + zeroBytes(data.HBHKey) + for index := range data.Endpoints { + endpoint := &data.Endpoints[index] + zeroBytes(endpoint.RawToken) + zeroBytes(endpoint.RawAuthToken) + zeroBytes(endpoint.AddressBytes) + endpoint.Token = "" + endpoint.AuthToken = "" + endpoint.Key = "" + endpoint.RelayName = "" + endpoint.AuthTokenID = "" + endpoint.RawToken = nil + endpoint.RawAuthToken = nil + endpoint.AddressBytes = nil + endpoint.C2RRtt = nil + } + for index := range data.ParticipantJIDs { + data.ParticipantJIDs[index] = "" + } + data.Endpoints = nil + data.ParticipantJIDs = nil + data.UUID = "" + data.SelfPID = nil + data.PeerPID = nil + data.HBHKey = nil +} + +func cloneInt(value *int) *int { + if value == nil { + return nil + } + clone := *value + return &clone +} + +func zeroBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} From a00c28768c525ede4391da12c691fc11038c3402 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:41:02 -0300 Subject: [PATCH 053/266] feat(call): capture outgoing call relay acknowledgements --- pkg/call/voip/driver/signaling.go | 84 ++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 7 deletions(-) diff --git a/pkg/call/voip/driver/signaling.go b/pkg/call/voip/driver/signaling.go index 3f98d14d..68fcd961 100644 --- a/pkg/call/voip/driver/signaling.go +++ b/pkg/call/voip/driver/signaling.go @@ -8,10 +8,35 @@ import ( "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" ) +// StartResult contains private negotiation material produced while starting a +// call. Callers must copy it into their private registry and then call Wipe. +type StartResult struct { + CallID string + Peer types.JID + Creator types.JID + CallKey []byte + RelayData *core.RelayData +} + +// Wipe removes private material from this transient result after it has been +// copied into the per-instance call registry. +func (r *StartResult) Wipe() { + if r == nil { + return + } + for index := range r.CallKey { + r.CallKey[index] = 0 + } + core.ZeroRelayData(r.RelayData) + r.CallKey = nil + r.RelayData = nil +} + // SignalingDriver sends real WhatsApp call stanzas. Media transport is not yet // attached, so a successful offer means the peer can ring and emit lifecycle // events, not that bidirectional audio is available. @@ -23,24 +48,69 @@ func NewSignalingDriver(client *whatsmeow.Client) *SignalingDriver { return &SignalingDriver{socket: wa.NewSocket(client)} } -func (d *SignalingDriver) Start(ctx context.Context, peer types.JID, video bool) (string, types.JID, error) { +func (d *SignalingDriver) Start(ctx context.Context, peer types.JID, video bool) (*StartResult, error) { if peer.IsEmpty() { - return "", types.JID{}, fmt.Errorf("peer JID is empty") + return nil, fmt.Errorf("peer JID is empty") + } + + creator := d.socket.OwnLID() + if creator.IsEmpty() { + creator = d.socket.OwnPN() } + if creator.IsEmpty() { + return nil, fmt.Errorf("whatsapp client has no own JID") + } + callID := signaling.GenerateCallID() callKey, err := signaling.GenerateCallKey() if err != nil { - return "", types.JID{}, err + return nil, err } + wipeOnError := func() { + for index := range callKey { + callKey[index] = 0 + } + } + resolvedPeer := d.socket.ResolveLIDForPN(ctx, peer) offer, err := signaling.BuildOfferStanza(ctx, d.socket, callID, callKey, resolvedPeer, video) if err != nil { - return "", types.JID{}, err + wipeOnError() + return nil, err } - if err := d.socket.SendNode(ctx, offer); err != nil { - return "", types.JID{}, fmt.Errorf("send call offer: %w", err) + + ack, err := d.socket.Query(ctx, offer) + if err != nil { + wipeOnError() + return nil, fmt.Errorf("send call offer: %w", err) } - return callID, resolvedPeer, nil + + var relayData *core.RelayData + if ack != nil { + if ackError := wanode.AttrString(ack.Attrs, "error"); ackError != "" { + wipeOnError() + return nil, fmt.Errorf("call offer rejected by WhatsApp: %s", ackError) + } + parsed := signaling.ParseRelayFromAck(ack) + if len(parsed.Relays) > 0 || parsed.UUID != "" || len(parsed.HBHKey) > 0 { + relayData = &core.RelayData{ + Endpoints: parsed.Relays, + ParticipantJIDs: parsed.ParticipantJIDs, + UUID: parsed.UUID, + SelfPID: parsed.SelfPID, + PeerPID: parsed.PeerPID, + HBHKey: parsed.HBHKey, + } + } + } + + return &StartResult{ + CallID: callID, + Peer: resolvedPeer, + Creator: creator, + CallKey: callKey, + RelayData: relayData, + }, nil } func (d *SignalingDriver) EndOutgoing(ctx context.Context, callID string, peer types.JID) error { From 5c96082c224072acf12d929169c2c0d6118bdf90 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:41:41 -0300 Subject: [PATCH 054/266] feat(call): associate private relay data with calls --- pkg/call/voip/incoming/registry.go | 266 ++++++++++++++++++++++------- 1 file changed, 202 insertions(+), 64 deletions(-) diff --git a/pkg/call/voip/incoming/registry.go b/pkg/call/voip/incoming/registry.go index 1a82f95a..84a41446 100644 --- a/pkg/call/voip/incoming/registry.go +++ b/pkg/call/voip/incoming/registry.go @@ -1,6 +1,6 @@ -// Package incoming keeps private material required to accept WhatsApp calls. -// Call keys and device metadata are intentionally separated from the public -// runtime snapshots and are never serialized. +// Package incoming keeps private material required by WhatsApp call negotiation. +// Call keys, relay tokens and device metadata are intentionally separated from +// public runtime snapshots and are never serialized. package incoming import ( @@ -9,33 +9,42 @@ import ( "sync" "time" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" "go.mau.fi/whatsmeow" + waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" ) const prepareTimeout = 30 * time.Second -type callSecret struct { - callKey []byte - peer types.JID - creator types.JID - video bool +type callMaterial struct { + callKey []byte + peer types.JID + creator types.JID + video bool + relayData *core.RelayData } type session struct { - mu sync.RWMutex - client *whatsmeow.Client - handlerID uint32 - secrets map[string]*callSecret + mu sync.RWMutex + client *whatsmeow.Client + handlerID uint32 + prepareIncoming bool + materials map[string]*callMaterial } -func newSession(client *whatsmeow.Client) *session { +func newSession(client *whatsmeow.Client, prepareIncoming ...bool) *session { + enabled := true + if len(prepareIncoming) > 0 { + enabled = prepareIncoming[0] + } s := &session{ - client: client, - secrets: make(map[string]*callSecret), + client: client, + prepareIncoming: enabled, + materials: make(map[string]*callMaterial), } if client != nil { s.handlerID = client.AddEventHandler(s.handleEvent) @@ -49,12 +58,27 @@ func (s *session) usesClient(client *whatsmeow.Client) bool { return s.client == client && client != nil } +func (s *session) setPrepareIncoming(enabled bool) { + s.mu.Lock() + s.prepareIncoming = enabled + s.mu.Unlock() +} + func (s *session) handleEvent(rawEvent interface{}) { switch event := rawEvent.(type) { case *events.CallOffer: - // Decrypting the Signal payload and sending preaccept must not block the - // main Evolution event dispatcher. - go s.prepareOffer(event) + s.mu.RLock() + prepareIncoming := s.prepareIncoming + s.mu.RUnlock() + if prepareIncoming { + // Decrypting the Signal payload and sending preaccept must not block + // the main Evolution event dispatcher. + go s.prepareOffer(event) + } + case *events.CallAccept: + s.captureRelays(event.CallID, event.Data) + case *events.CallTransport: + s.captureRelays(event.CallID, event.Data) case *events.CallReject: s.remove(event.CallID) case *events.CallTerminate: @@ -73,8 +97,9 @@ func (s *session) prepareOffer(event *events.CallOffer) { s.mu.RLock() client := s.client + prepareIncoming := s.prepareIncoming s.mu.RUnlock() - if client == nil { + if client == nil || !prepareIncoming { return } @@ -103,14 +128,15 @@ func (s *session) prepareOffer(event *events.CallOffer) { return } - secret := &callSecret{ - callKey: append([]byte(nil), callKey...), - peer: peer, - creator: creator, - video: signaling.NodeContainsVideo(event.Data), + material := &callMaterial{ + callKey: append([]byte(nil), callKey...), + peer: peer, + creator: creator, + video: signaling.NodeContainsVideo(event.Data), + relayData: relayDataFromNode(event.Data), } zeroBytes(callKey) - s.store(event.CallID, secret) + s.store(event.CallID, material) // WhatsApp expects preaccept before the user explicitly accepts the call. // A send failure does not expose or discard the key; the accept endpoint will @@ -118,12 +144,45 @@ func (s *session) prepareOffer(event *events.CallOffer) { _ = socket.SendNode(ctx, signaling.BuildPreacceptStanza(peer, event.CallID, creator)) } +func (s *session) storeOutgoing(callID string, callKey []byte, peer, creator types.JID, video bool, relayData *core.RelayData) { + if callID == "" || len(callKey) == 0 || peer.IsEmpty() || creator.IsEmpty() { + return + } + s.store(callID, &callMaterial{ + callKey: append([]byte(nil), callKey...), + peer: peer, + creator: creator, + video: video, + relayData: core.CloneRelayData(relayData), + }) +} + +func (s *session) captureRelays(callID string, node *waBinary.Node) { + if callID == "" || node == nil { + return + } + relayData := relayDataFromNode(node) + if relayData == nil { + return + } + + s.mu.Lock() + material := s.materials[callID] + if material == nil { + material = &callMaterial{} + s.materials[callID] = material + } + core.ZeroRelayData(material.relayData) + material.relayData = relayData + s.mu.Unlock() +} + func (s *session) accept(ctx context.Context, callID string) error { - secret, ok := s.copySecret(callID) - if !ok { + material, ok := s.copyMaterial(callID) + if !ok || len(material.callKey) == 0 { return fmt.Errorf("incoming call %s is not ready to accept", callID) } - defer zeroBytes(secret.callKey) + defer zeroMaterial(material) s.mu.RLock() client := s.client @@ -137,10 +196,10 @@ func (s *session) accept(ctx context.Context, callID string) error { ctx, socket, callID, - secret.callKey, - secret.peer, - secret.creator, - secret.video, + material.callKey, + material.peer, + material.creator, + material.video, ) if err != nil { return fmt.Errorf("build call accept: %w", err) @@ -152,72 +211,88 @@ func (s *session) accept(ctx context.Context, callID string) error { } func (s *session) terminate(ctx context.Context, callID string) error { - secret, ok := s.copySecret(callID) - if !ok { - return fmt.Errorf("incoming call %s has no private signaling material", callID) + material, ok := s.copyMaterial(callID) + if !ok || material.peer.IsEmpty() || material.creator.IsEmpty() { + return fmt.Errorf("call %s has no private signaling material", callID) } - defer zeroBytes(secret.callKey) + defer zeroMaterial(material) s.mu.RLock() client := s.client s.mu.RUnlock() if client == nil { - return fmt.Errorf("incoming call session is detached") + return fmt.Errorf("call session is detached") } - node := signaling.BuildTerminateStanza(secret.peer, callID, secret.creator) + node := signaling.BuildTerminateStanza(material.peer, callID, material.creator) if err := wa.NewSocket(client).SendNode(ctx, node); err != nil { - return fmt.Errorf("send incoming call terminate: %w", err) + return fmt.Errorf("send call terminate: %w", err) } s.remove(callID) return nil } -func (s *session) store(callID string, secret *callSecret) { - if callID == "" || secret == nil { +func (s *session) store(callID string, material *callMaterial) { + if callID == "" || material == nil { return } s.mu.Lock() - if previous := s.secrets[callID]; previous != nil { - zeroBytes(previous.callKey) + if previous := s.materials[callID]; previous != nil { + if material.relayData == nil { + material.relayData = core.CloneRelayData(previous.relayData) + } + zeroMaterial(previous) } - s.secrets[callID] = secret + s.materials[callID] = material s.mu.Unlock() } -func (s *session) copySecret(callID string) (*callSecret, bool) { +func (s *session) copyMaterial(callID string) (*callMaterial, bool) { s.mu.RLock() - secret, ok := s.secrets[callID] - if !ok || secret == nil { + material, ok := s.materials[callID] + if !ok || material == nil { s.mu.RUnlock() return nil, false } - copyValue := &callSecret{ - callKey: append([]byte(nil), secret.callKey...), - peer: secret.peer, - creator: secret.creator, - video: secret.video, + copyValue := &callMaterial{ + callKey: append([]byte(nil), material.callKey...), + peer: material.peer, + creator: material.creator, + video: material.video, + relayData: core.CloneRelayData(material.relayData), } s.mu.RUnlock() return copyValue, true } +func (s *session) relayData(callID string) (*core.RelayData, bool) { + s.mu.RLock() + material := s.materials[callID] + if material == nil || material.relayData == nil { + s.mu.RUnlock() + return nil, false + } + data := core.CloneRelayData(material.relayData) + s.mu.RUnlock() + return data, true +} + func (s *session) remove(callID string) { s.mu.Lock() - if secret := s.secrets[callID]; secret != nil { - zeroBytes(secret.callKey) + if material := s.materials[callID]; material != nil { + zeroMaterial(material) } - delete(s.secrets, callID) + delete(s.materials, callID) s.mu.Unlock() } func (s *session) clear() { s.mu.Lock() - for callID, secret := range s.secrets { - if secret != nil { - zeroBytes(secret.callKey) + for callID, material := range s.materials { + if material != nil { + zeroMaterial(material) } - delete(s.secrets, callID) + delete(s.materials, callID) } s.mu.Unlock() } @@ -236,13 +311,49 @@ func (s *session) close() { s.clear() } +func relayDataFromNode(node *waBinary.Node) *core.RelayData { + if node == nil { + return nil + } + + endpoints := signaling.ExtractRelayEndpoints(node) + parsed := signaling.ParseRelayFromAck(node) + if len(endpoints) == 0 { + endpoints = parsed.Relays + } + if len(endpoints) == 0 && parsed.UUID == "" && len(parsed.HBHKey) == 0 && + len(parsed.ParticipantJIDs) == 0 && parsed.SelfPID == nil && parsed.PeerPID == nil { + return nil + } + return &core.RelayData{ + Endpoints: endpoints, + ParticipantJIDs: parsed.ParticipantJIDs, + UUID: parsed.UUID, + SelfPID: parsed.SelfPID, + PeerPID: parsed.PeerPID, + HBHKey: parsed.HBHKey, + } +} + +func zeroMaterial(material *callMaterial) { + if material == nil { + return + } + zeroBytes(material.callKey) + core.ZeroRelayData(material.relayData) + material.callKey = nil + material.peer = types.JID{} + material.creator = types.JID{} + material.relayData = nil +} + func zeroBytes(value []byte) { for index := range value { value[index] = 0 } } -// Registry stores one private incoming-call session per Evolution instance. +// Registry stores one private call-negotiation session per Evolution instance. type Registry struct { mu sync.RWMutex sessions map[string]*session @@ -253,21 +364,27 @@ func NewRegistry() *Registry { } // Attach installs an isolated call handler on the same authenticated client used -// by messaging. Reattaching the same pointer is idempotent. -func (r *Registry) Attach(instanceID string, client *whatsmeow.Client) { +// by messaging. Reattaching the same pointer is idempotent. prepareIncoming may +// be disabled for instances configured to reject calls automatically. +func (r *Registry) Attach(instanceID string, client *whatsmeow.Client, prepareIncoming ...bool) { if instanceID == "" || client == nil { return } + enabled := true + if len(prepareIncoming) > 0 { + enabled = prepareIncoming[0] + } r.mu.RLock() current := r.sessions[instanceID] if current != nil && current.usesClient(client) { + current.setPrepareIncoming(enabled) r.mu.RUnlock() return } r.mu.RUnlock() - candidate := newSession(client) + candidate := newSession(client, enabled) r.mu.Lock() previous := r.sessions[instanceID] @@ -279,6 +396,27 @@ func (r *Registry) Attach(instanceID string, client *whatsmeow.Client) { } } +func (r *Registry) StoreOutgoing(instanceID, callID string, callKey []byte, peer, creator types.JID, video bool, relayData *core.RelayData) error { + r.mu.RLock() + s := r.sessions[instanceID] + r.mu.RUnlock() + if s == nil { + return fmt.Errorf("call runtime is not attached for instance %s", instanceID) + } + s.storeOutgoing(callID, callKey, peer, creator, video, relayData) + return nil +} + +func (r *Registry) RelayData(instanceID, callID string) (*core.RelayData, bool) { + r.mu.RLock() + s := r.sessions[instanceID] + r.mu.RUnlock() + if s == nil { + return nil, false + } + return s.relayData(callID) +} + func (r *Registry) Accept(ctx context.Context, instanceID, callID string) error { r.mu.RLock() s := r.sessions[instanceID] @@ -294,7 +432,7 @@ func (r *Registry) Terminate(ctx context.Context, instanceID, callID string) err s := r.sessions[instanceID] r.mu.RUnlock() if s == nil { - return fmt.Errorf("incoming call runtime is not attached for instance %s", instanceID) + return fmt.Errorf("call runtime is not attached for instance %s", instanceID) } return s.terminate(ctx, callID) } From 09dc7679c6cfae3ea4d4e252478fd844d90822cc Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:41:56 -0300 Subject: [PATCH 055/266] feat(call): coordinate private outgoing negotiation state --- pkg/call/lifecycle/coordinator.go | 43 +++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go index 966a66fa..1e102fc6 100644 --- a/pkg/call/lifecycle/coordinator.go +++ b/pkg/call/lifecycle/coordinator.go @@ -1,4 +1,4 @@ -// Package lifecycle coordinates call state and private incoming-call material +// Package lifecycle coordinates call state and private negotiation material // for each Evolution WhatsApp client. package lifecycle @@ -7,8 +7,10 @@ import ( "sync" call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" call_incoming "github.com/evolution-foundation/evolution-go/pkg/call/voip/incoming" "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" ) // Coordinator owns the call registries shared by the WhatsApp lifecycle and @@ -29,8 +31,8 @@ func NewCoordinator() *Coordinator { } // AttachClient is called by the WhatsApp client lifecycle. Public call state is -// always monitored, while private offer preparation is disabled for instances -// configured to reject incoming calls automatically. +// always monitored. Private outgoing negotiation remains available even when +// incoming offer preparation is disabled by automatic rejection settings. func (c *Coordinator) AttachClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool) { if c == nil || instanceID == "" || client == nil { return @@ -41,11 +43,7 @@ func (c *Coordinator) AttachClient(instanceID string, client *whatsmeow.Client, c.mu.Unlock() c.runtimes.Attach(instanceID, client) - if prepareIncoming { - c.incoming.Attach(instanceID, client) - } else { - c.incoming.Close(instanceID) - } + c.incoming.Attach(instanceID, client, prepareIncoming) } // DetachClient removes handlers, configuration and private call keys. @@ -71,9 +69,10 @@ func (c *Coordinator) Attach(instanceID string, client *whatsmeow.Client) { c.mu.RLock() prepareIncoming, configured := c.incomingEnabled[instanceID] c.mu.RUnlock() - if !configured || prepareIncoming { - c.incoming.Attach(instanceID, client) + if !configured { + prepareIncoming = true } + c.incoming.Attach(instanceID, client, prepareIncoming) } func (c *Coordinator) Detach(instanceID string) { @@ -96,6 +95,22 @@ func (c *Coordinator) RuntimeFor(instanceID string, client *whatsmeow.Client) *c return runtime } +func (c *Coordinator) StoreOutgoing(instanceID, callID string, callKey []byte, peer, creator types.JID, video bool, relayData *core.RelayData) error { + if c == nil { + return nil + } + return c.incoming.StoreOutgoing(instanceID, callID, callKey, peer, creator, video, relayData) +} + +// RelayData returns a defensive copy for the future SCTP transport manager. +// The caller owns the copy and must call core.ZeroRelayData after use. +func (c *Coordinator) RelayData(instanceID, callID string) (*core.RelayData, bool) { + if c == nil { + return nil, false + } + return c.incoming.RelayData(instanceID, callID) +} + func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID string) error { return c.incoming.Accept(ctx, instanceID, callID) } @@ -104,6 +119,12 @@ func (c *Coordinator) TerminateIncoming(ctx context.Context, instanceID, callID return c.incoming.Terminate(ctx, instanceID, callID) } -func (c *Coordinator) RemoveIncoming(instanceID, callID string) { +func (c *Coordinator) RemovePrivate(instanceID, callID string) { c.incoming.Remove(instanceID, callID) } + +// RemoveIncoming is kept as a compatibility alias while call-service code is +// migrated to direction-neutral private negotiation storage. +func (c *Coordinator) RemoveIncoming(instanceID, callID string) { + c.RemovePrivate(instanceID, callID) +} From 267c2f209ddd305d9466ae51aae768bf71c29e33 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:42:30 -0300 Subject: [PATCH 056/266] feat(call): persist outgoing private negotiation material --- pkg/call/service/call_service.go | 43 +++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/pkg/call/service/call_service.go b/pkg/call/service/call_service.go index db669c61..942d58db 100644 --- a/pkg/call/service/call_service.go +++ b/pkg/call/service/call_service.go @@ -79,8 +79,8 @@ func (c *callService) ensureClientConnected(instanceID string) (*whatsmeow.Clien return nil, errors.New("client disconnected") } - // Calls and messaging share the same authenticated client. The public runtime - // tracks state while the private incoming registry holds non-serializable keys. + // Calls and messaging share the same authenticated client. Public state and + // private call negotiation are attached idempotently to that client. c.coordinator.Attach(instanceID, client) c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Client successfully validated - Connected: %v", instanceID, client.IsConnected()) @@ -105,24 +105,47 @@ func (c *callService) StartCall(data *StartCallStruct, instance *instance_model. ctx, cancel := context.WithTimeout(context.Background(), signalingTimeout) defer cancel() - driver := call_driver.NewSignalingDriver(client) - callID, resolvedPeer, err := driver.Start(ctx, peer, data.Video) + result, err := call_driver.NewSignalingDriver(client).Start(ctx, peer, data.Video) if err != nil { return call_runtime.Call{}, err } + defer result.Wipe() + + if err := c.coordinator.StoreOutgoing( + instance.Id, + result.CallID, + result.CallKey, + result.Peer, + result.Creator, + data.Video, + result.RelayData, + ); err != nil { + return call_runtime.Call{}, err + } runtime := c.coordinator.RuntimeFor(instance.Id, client) video := data.Video runtime.Transition( - callID, - resolvedPeer.String(), + result.CallID, + result.Peer.String(), call_runtime.DirectionOutgoing, call_runtime.StateRinging, &video, "", ) - call, _ := runtime.Call(callID) - c.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Call offer sent - CallID: %s, Peer: %s, Video: %v", instance.Id, callID, resolvedPeer.String(), data.Video) + call, _ := runtime.Call(result.CallID) + relayCount := 0 + if result.RelayData != nil { + relayCount = len(result.RelayData.Endpoints) + } + c.loggerWrapper.GetLogger(instance.Id).LogInfo( + "[%s] Call offer sent - CallID: %s, Peer: %s, Video: %v, Relays: %d", + instance.Id, + result.CallID, + result.Peer.String(), + data.Video, + relayCount, + ) return call, nil } @@ -168,6 +191,7 @@ func (c *callService) TerminateCall(callID string, instance *instance_model.Inst return call_runtime.Call{}, fmt.Errorf("call %s not found", callID) } if call.State == call_runtime.StateEnded || call.State == call_runtime.StateFailed { + c.coordinator.RemovePrivate(instance.Id, callID) return call, nil } @@ -188,6 +212,7 @@ func (c *callService) TerminateCall(callID string, instance *instance_model.Inst } } + c.coordinator.RemovePrivate(instance.Id, callID) runtime.Transition(callID, "", "", call_runtime.StateEnded, nil, "user_ended") call, _ = runtime.Call(callID) return call, nil @@ -204,7 +229,7 @@ func (c *callService) RejectCall(data *RejectCallStruct, instance *instance_mode return err } - c.coordinator.RemoveIncoming(instance.Id, data.CallID) + c.coordinator.RemovePrivate(instance.Id, data.CallID) runtime := c.coordinator.RuntimeFor(instance.Id, client) runtime.Transition(data.CallID, data.CallCreator.String(), call_runtime.DirectionIncoming, call_runtime.StateEnded, nil, "rejected") return nil From 850f03e85811093b81bf3912db3baca60c123138 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:43:00 -0300 Subject: [PATCH 057/266] test(call): cover private call and relay material lifecycle --- pkg/call/voip/incoming/registry_test.go | 123 ++++++++++++++++++++---- 1 file changed, 106 insertions(+), 17 deletions(-) diff --git a/pkg/call/voip/incoming/registry_test.go b/pkg/call/voip/incoming/registry_test.go index d7efd946..196f9d24 100644 --- a/pkg/call/voip/incoming/registry_test.go +++ b/pkg/call/voip/incoming/registry_test.go @@ -3,59 +3,85 @@ package incoming import ( "testing" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/types" ) -func TestSecretCopyIsIndependent(t *testing.T) { - s := &session{secrets: make(map[string]*callSecret)} +func newTestSession() *session { + return &session{materials: make(map[string]*callMaterial), prepareIncoming: true} +} + +func TestMaterialCopyIsIndependent(t *testing.T) { + s := newTestSession() key := make([]byte, 32) for index := range key { key[index] = byte(index + 1) } - s.store("call-1", &callSecret{ + originalToken := []byte{7, 8, 9} + s.store("call-1", &callMaterial{ callKey: key, peer: types.NewJID("5511999999999", types.DefaultUserServer), creator: types.NewJID("5511999999999", types.HiddenUserServer), + relayData: &core.RelayData{Endpoints: []core.RelayEndpoint{{ + IP: "1.2.3.4", + RawToken: originalToken, + }}}, }) - copyValue, ok := s.copySecret("call-1") + copyValue, ok := s.copyMaterial("call-1") if !ok { - t.Fatal("expected secret copy") + t.Fatal("expected material copy") } copyValue.callKey[0] = 99 - stored, _ := s.copySecret("call-1") + copyValue.relayData.Endpoints[0].RawToken[0] = 99 + stored, _ := s.copyMaterial("call-1") if stored.callKey[0] != 1 { - t.Fatal("mutating a secret copy changed the stored key") + t.Fatal("mutating a material copy changed the stored key") } - zeroBytes(copyValue.callKey) - zeroBytes(stored.callKey) + if stored.relayData.Endpoints[0].RawToken[0] != 7 { + t.Fatal("mutating a relay copy changed the stored token") + } + zeroMaterial(copyValue) + zeroMaterial(stored) } -func TestRemoveZeroesStoredKey(t *testing.T) { - s := &session{secrets: make(map[string]*callSecret)} +func TestRemoveZeroesStoredMaterial(t *testing.T) { + s := newTestSession() key := make([]byte, 32) for index := range key { key[index] = 7 } - s.store("call-1", &callSecret{callKey: key}) + token := []byte{4, 5, 6} + s.store("call-1", &callMaterial{ + callKey: key, + relayData: &core.RelayData{Endpoints: []core.RelayEndpoint{{ + RawToken: token, + }}}, + }) s.remove("call-1") - if _, ok := s.copySecret("call-1"); ok { - t.Fatal("secret was not removed") + if _, ok := s.copyMaterial("call-1"); ok { + t.Fatal("material was not removed") } for index, value := range key { if value != 0 { t.Fatalf("key byte %d was not zeroed: %d", index, value) } } + for index, value := range token { + if value != 0 { + t.Fatalf("token byte %d was not zeroed: %d", index, value) + } + } } func TestClearZeroesAllKeys(t *testing.T) { - s := &session{secrets: make(map[string]*callSecret)} + s := newTestSession() first := []byte{1, 2, 3} second := []byte{4, 5, 6} - s.store("first", &callSecret{callKey: first}) - s.store("second", &callSecret{callKey: second}) + s.store("first", &callMaterial{callKey: first}) + s.store("second", &callMaterial{callKey: second}) s.clear() for _, key := range [][]byte{first, second} { @@ -63,6 +89,69 @@ func TestClearZeroesAllKeys(t *testing.T) { if value != 0 { t.Fatalf("key was not zeroed: %v", key) } + } + } +} + +func TestTransportRelayUpdatePreservesCallKey(t *testing.T) { + s := newTestSession() + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + s.store("call-1", &callMaterial{callKey: key}) + + node := &waBinary.Node{Content: []waBinary.Node{{ + Tag: "relay", + Attrs: waBinary.Attrs{ + "ip": "10.0.0.8", + "port": "3480", + "token": "relay-token", + }, + }}} + s.captureRelays("call-1", node) + + stored, ok := s.copyMaterial("call-1") + if !ok { + t.Fatal("expected stored material") + } + defer zeroMaterial(stored) + if len(stored.callKey) != 32 || stored.callKey[0] != 1 { + t.Fatal("relay update erased the call key") + } + if stored.relayData == nil || len(stored.relayData.Endpoints) != 1 { + t.Fatal("relay update was not stored") + } + if stored.relayData.Endpoints[0].IP != "10.0.0.8" { + t.Fatalf("unexpected relay IP: %s", stored.relayData.Endpoints[0].IP) + } +} + +func TestStoreMergesRelayCapturedBeforeKey(t *testing.T) { + s := newTestSession() + node := &waBinary.Node{Content: []waBinary.Node{{ + Tag: "relay", + Attrs: waBinary.Attrs{ + "ip": "10.0.0.9", + "port": "3480", + "token": "relay-token", + }, + }}} + s.captureRelays("call-1", node) + + key := make([]byte, 32) + key[0] = 42 + s.store("call-1", &callMaterial{callKey: key}) + + stored, ok := s.copyMaterial("call-1") + if !ok { + t.Fatal("expected stored material") + } + defer zeroMaterial(stored) + if stored.callKey[0] != 42 { + t.Fatal("call key was not stored") } + if stored.relayData == nil || stored.relayData.Endpoints[0].IP != "10.0.0.9" { + t.Fatal("relay captured before key was not preserved") } } From 0ba097f9a27237975faf45b7d35b332b8381245c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:43:21 -0300 Subject: [PATCH 058/266] test(call): verify relay material cloning and cleanup --- pkg/call/voip/core/relay_test.go | 71 ++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 pkg/call/voip/core/relay_test.go diff --git a/pkg/call/voip/core/relay_test.go b/pkg/call/voip/core/relay_test.go new file mode 100644 index 00000000..e66dbeb0 --- /dev/null +++ b/pkg/call/voip/core/relay_test.go @@ -0,0 +1,71 @@ +package core + +import "testing" + +func TestCloneRelayDataIsDeepCopy(t *testing.T) { + rtt := 18 + selfPID := 4 + original := &RelayData{ + Endpoints: []RelayEndpoint{{ + IP: "1.2.3.4", + RawToken: []byte{1, 2, 3}, + RawAuthToken: []byte{4, 5, 6}, + AddressBytes: []byte{1, 2, 3, 4, 13, 152}, + C2RRtt: &rtt, + }}, + ParticipantJIDs: []string{"device@s.whatsapp.net"}, + UUID: "relay-uuid", + SelfPID: &selfPID, + HBHKey: []byte{7, 8, 9}, + } + + clone := CloneRelayData(original) + clone.Endpoints[0].RawToken[0] = 99 + clone.Endpoints[0].RawAuthToken[0] = 99 + clone.Endpoints[0].AddressBytes[0] = 99 + *clone.Endpoints[0].C2RRtt = 99 + clone.ParticipantJIDs[0] = "changed" + clone.HBHKey[0] = 99 + *clone.SelfPID = 99 + + if original.Endpoints[0].RawToken[0] != 1 || original.Endpoints[0].RawAuthToken[0] != 4 { + t.Fatal("clone shares token buffers with original") + } + if original.Endpoints[0].AddressBytes[0] != 1 || *original.Endpoints[0].C2RRtt != 18 { + t.Fatal("clone shares endpoint metadata with original") + } + if original.ParticipantJIDs[0] != "device@s.whatsapp.net" || original.HBHKey[0] != 7 || *original.SelfPID != 4 { + t.Fatal("clone shares relay metadata with original") + } +} + +func TestZeroRelayDataOverwritesBuffers(t *testing.T) { + rawToken := []byte{1, 2, 3} + rawAuth := []byte{4, 5, 6} + address := []byte{7, 8, 9, 10, 13, 152} + hbh := []byte{11, 12, 13} + data := &RelayData{ + Endpoints: []RelayEndpoint{{ + Token: "token", + AuthToken: "auth", + RawToken: rawToken, + RawAuthToken: rawAuth, + AddressBytes: address, + Key: "key", + }}, + ParticipantJIDs: []string{"device@s.whatsapp.net"}, + HBHKey: hbh, + } + + ZeroRelayData(data) + for _, buffer := range [][]byte{rawToken, rawAuth, address, hbh} { + for _, value := range buffer { + if value != 0 { + t.Fatalf("private relay buffer was not overwritten: %v", buffer) + } + } + } + if data.Endpoints != nil || data.ParticipantJIDs != nil || data.HBHKey != nil { + t.Fatal("relay references were not cleared") + } +} From 202c84c3b4954f350df8852128adeb1ddd146808 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:45:07 -0300 Subject: [PATCH 059/266] feat(call): port strict call state machine --- pkg/call/voip/call/state.go | 207 ++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 pkg/call/voip/call/state.go diff --git a/pkg/call/voip/call/state.go b/pkg/call/voip/call/state.go new file mode 100644 index 00000000..c2d8ee15 --- /dev/null +++ b/pkg/call/voip/call/state.go @@ -0,0 +1,207 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package call + +import ( + "fmt" + "time" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +type StateData struct { + State core.CallState + ConnectedAt *time.Time + AcceptedAt *time.Time + EndedAt *time.Time + AudioMuted bool + VideoOff bool + Silenced bool + EndReason core.EndCallReason + DurationSecs int +} + +type Info struct { + CallID string + PeerJID string + CallCreator string + Direction core.CallDirection + MediaType core.CallMediaType + StateData StateData + CreatedAt time.Time +} + +func NewOutgoing(callID, peerJID, creator string, mediaType core.CallMediaType) *Info { + return &Info{ + CallID: callID, + PeerJID: peerJID, + CallCreator: creator, + Direction: core.CallDirectionOutgoing, + MediaType: mediaType, + CreatedAt: time.Now().UTC(), + StateData: StateData{ + State: core.CallStateInitiating, + VideoOff: mediaType != core.CallMediaTypeVideo, + AudioMuted: false, + }, + } +} + +func NewIncoming(callID, peerJID, creator string, mediaType core.CallMediaType) *Info { + return &Info{ + CallID: callID, + PeerJID: peerJID, + CallCreator: creator, + Direction: core.CallDirectionIncoming, + MediaType: mediaType, + CreatedAt: time.Now().UTC(), + StateData: StateData{ + State: core.CallStateIncomingRinging, + VideoOff: mediaType != core.CallMediaTypeVideo, + AudioMuted: false, + }, + } +} + +func (c *Info) IsInitiator() bool { return c != nil && c.Direction == core.CallDirectionOutgoing } +func (c *Info) IsActive() bool { return c != nil && c.StateData.State == core.CallStateActive } +func (c *Info) IsEnded() bool { return c != nil && c.StateData.State == core.CallStateEnded } +func (c *Info) CanAccept() bool { + return c != nil && c.StateData.State == core.CallStateIncomingRinging +} + +func (c *Info) Clone() *Info { + if c == nil { + return nil + } + clone := *c + clone.StateData.ConnectedAt = cloneTime(c.StateData.ConnectedAt) + clone.StateData.AcceptedAt = cloneTime(c.StateData.AcceptedAt) + clone.StateData.EndedAt = cloneTime(c.StateData.EndedAt) + return &clone +} + +type TransitionType string + +const ( + TransitionOfferSent TransitionType = "offer_sent" + TransitionRemoteAccepted TransitionType = "remote_accepted" + TransitionLocalAccepted TransitionType = "local_accepted" + TransitionRemoteRejected TransitionType = "remote_rejected" + TransitionLocalRejected TransitionType = "local_rejected" + TransitionMediaConnected TransitionType = "media_connected" + TransitionTerminated TransitionType = "terminated" + TransitionHold TransitionType = "hold" + TransitionResume TransitionType = "resume" + TransitionAudioMuteChanged TransitionType = "audio_mute_changed" + TransitionVideoStateChanged TransitionType = "video_state_changed" +) + +type Transition struct { + Type TransitionType + Reason core.EndCallReason + Muted bool + Off bool +} + +type InvalidTransition struct { + CurrentState core.CallState + Attempted TransitionType +} + +func (e *InvalidTransition) Error() string { + return fmt.Sprintf("invalid transition %q in state %q", e.Attempted, e.CurrentState) +} + +func (c *Info) Apply(transition Transition) error { + if c == nil { + return fmt.Errorf("nil call state") + } + state := &c.StateData + now := time.Now().UTC() + + switch transition.Type { + case TransitionOfferSent: + if state.State != core.CallStateInitiating { + return invalid(state.State, transition.Type) + } + state.State = core.CallStateRinging + case TransitionRemoteAccepted: + if state.State != core.CallStateRinging { + return invalid(state.State, transition.Type) + } + state.State = core.CallStateConnecting + state.AcceptedAt = &now + case TransitionLocalAccepted: + if state.State != core.CallStateIncomingRinging { + return invalid(state.State, transition.Type) + } + state.State = core.CallStateConnecting + state.AcceptedAt = &now + case TransitionRemoteRejected: + if state.State != core.CallStateRinging { + return invalid(state.State, transition.Type) + } + endState(state, now, transition.Reason) + case TransitionLocalRejected: + if state.State != core.CallStateIncomingRinging { + return invalid(state.State, transition.Type) + } + endState(state, now, transition.Reason) + case TransitionMediaConnected: + if state.State != core.CallStateConnecting { + return invalid(state.State, transition.Type) + } + state.State = core.CallStateActive + state.ConnectedAt = &now + state.VideoOff = c.MediaType != core.CallMediaTypeVideo + case TransitionTerminated: + if state.State == core.CallStateEnded { + return invalid(state.State, transition.Type) + } + if (state.State == core.CallStateActive || state.State == core.CallStateOnHold) && state.ConnectedAt != nil { + state.DurationSecs = int(now.Sub(*state.ConnectedAt).Seconds()) + } + endState(state, now, transition.Reason) + case TransitionHold: + if state.State != core.CallStateActive { + return invalid(state.State, transition.Type) + } + state.State = core.CallStateOnHold + case TransitionResume: + if state.State != core.CallStateOnHold { + return invalid(state.State, transition.Type) + } + state.State = core.CallStateActive + case TransitionAudioMuteChanged: + if state.State != core.CallStateActive { + return invalid(state.State, transition.Type) + } + state.AudioMuted = transition.Muted + case TransitionVideoStateChanged: + if state.State != core.CallStateActive { + return invalid(state.State, transition.Type) + } + state.VideoOff = transition.Off + default: + return invalid(state.State, transition.Type) + } + return nil +} + +func invalid(state core.CallState, transition TransitionType) error { + return &InvalidTransition{CurrentState: state, Attempted: transition} +} + +func endState(state *StateData, now time.Time, reason core.EndCallReason) { + state.State = core.CallStateEnded + state.EndedAt = &now + state.EndReason = reason +} + +func cloneTime(value *time.Time) *time.Time { + if value == nil { + return nil + } + clone := *value + return &clone +} From d4efc1771c695f679354352a72dc8c1df7ad2bdd Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:45:24 -0300 Subject: [PATCH 060/266] test(call): validate strict call transitions --- pkg/call/voip/call/state_test.go | 78 ++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 pkg/call/voip/call/state_test.go diff --git a/pkg/call/voip/call/state_test.go b/pkg/call/voip/call/state_test.go new file mode 100644 index 00000000..9b70572d --- /dev/null +++ b/pkg/call/voip/call/state_test.go @@ -0,0 +1,78 @@ +package call + +import ( + "errors" + "testing" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +func TestOutgoingCallLifecycle(t *testing.T) { + call := NewOutgoing("call-1", "peer@s.whatsapp.net", "self@s.whatsapp.net", core.CallMediaTypeAudio) + steps := []Transition{ + {Type: TransitionOfferSent}, + {Type: TransitionRemoteAccepted}, + {Type: TransitionMediaConnected}, + {Type: TransitionAudioMuteChanged, Muted: true}, + {Type: TransitionHold}, + {Type: TransitionResume}, + {Type: TransitionTerminated, Reason: core.EndCallReasonUserEnded}, + } + for _, step := range steps { + if err := call.Apply(step); err != nil { + t.Fatalf("Apply(%s) error = %v", step.Type, err) + } + } + if !call.IsEnded() || call.StateData.EndReason != core.EndCallReasonUserEnded { + t.Fatalf("unexpected final state: %+v", call.StateData) + } + if !call.StateData.AudioMuted { + t.Fatal("audio mute state was not preserved") + } +} + +func TestIncomingAcceptLifecycle(t *testing.T) { + call := NewIncoming("call-2", "peer@s.whatsapp.net", "peer@s.whatsapp.net", core.CallMediaTypeVideo) + if !call.CanAccept() { + t.Fatal("incoming ringing call should be acceptable") + } + if err := call.Apply(Transition{Type: TransitionLocalAccepted}); err != nil { + t.Fatalf("local accept error = %v", err) + } + if err := call.Apply(Transition{Type: TransitionMediaConnected}); err != nil { + t.Fatalf("media connected error = %v", err) + } + if call.StateData.State != core.CallStateActive || call.StateData.VideoOff { + t.Fatalf("unexpected active video state: %+v", call.StateData) + } +} + +func TestInvalidTransitionDoesNotMutateState(t *testing.T) { + call := NewOutgoing("call-3", "peer@s.whatsapp.net", "self@s.whatsapp.net", core.CallMediaTypeAudio) + err := call.Apply(Transition{Type: TransitionMediaConnected}) + var invalidTransition *InvalidTransition + if !errors.As(err, &invalidTransition) { + t.Fatalf("expected InvalidTransition, got %v", err) + } + if call.StateData.State != core.CallStateInitiating { + t.Fatalf("invalid transition mutated state to %s", call.StateData.State) + } +} + +func TestCloneIsIndependent(t *testing.T) { + call := NewOutgoing("call-4", "peer@s.whatsapp.net", "self@s.whatsapp.net", core.CallMediaTypeAudio) + if err := call.Apply(Transition{Type: TransitionOfferSent}); err != nil { + t.Fatal(err) + } + if err := call.Apply(Transition{Type: TransitionRemoteAccepted}); err != nil { + t.Fatal(err) + } + clone := call.Clone() + clone.StateData.State = core.CallStateEnded + if clone.StateData.AcceptedAt != nil { + clone.StateData.AcceptedAt = nil + } + if call.StateData.State != core.CallStateConnecting || call.StateData.AcceptedAt == nil { + t.Fatal("mutating clone changed original") + } +} From 002e09d97339d1b63a6a35d7d6761950c94afcc0 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:45:49 -0300 Subject: [PATCH 061/266] feat(call): add relay transport contract and configuration builder --- pkg/call/voip/transport/relay.go | 139 +++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 pkg/call/voip/transport/relay.go diff --git a/pkg/call/voip/transport/relay.go b/pkg/call/voip/transport/relay.go new file mode 100644 index 00000000..4b7d159d --- /dev/null +++ b/pkg/call/voip/transport/relay.go @@ -0,0 +1,139 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package transport + +import ( + "errors" + "fmt" + "sync" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +var ErrSCTPUnavailable = errors.New("WhatsApp SCTP relay transport is not enabled") + +type RelayConfig struct { + IP string + Port int + Token string + AuthToken string + RawAuthToken []byte + RawToken []byte + Key string + RelayID int + Name string + AuthTokenID string +} + +// RelayTransport is the media-relay boundary used by the future CallManager. +// Implementations may use Pion, another WebRTC stack, or a deterministic fake. +type RelayTransport interface { + SetSSRC(ssrc uint32) + SetSubscriptionSSRC(ssrc uint32) + SetOnConnected(fn func(ip string, port int)) + SetOnReceive(fn func(data []byte)) + ResendSubscriptions() + ConfigureRelays(relays []RelayConfig) error + Broadcast(data []byte) error + HasConnection() bool + ConnectedCount() int + Cleanup() +} + +// BuildRelayConfigs converts protocol candidates into independent SCTP configs. +// Only UDP relay protocol 0 entries with credentials and a raw token are usable. +func BuildRelayConfigs(endpoints []core.RelayEndpoint) []RelayConfig { + seen := make(map[string]struct{}) + configs := make([]RelayConfig, 0, len(endpoints)) + for _, endpoint := range endpoints { + if endpoint.Protocol != 0 || endpoint.IP == "" || endpoint.Key == "" || len(endpoint.RawToken) == 0 { + continue + } + port := endpoint.Port + if port == 0 { + port = core.WARelayPort + } + identity := fmt.Sprintf("%s:%d#%s", endpoint.IP, port, endpoint.AuthTokenID) + if _, exists := seen[identity]; exists { + continue + } + seen[identity] = struct{}{} + + name := endpoint.RelayName + if name == "" { + name = endpoint.IP + } + configs = append(configs, RelayConfig{ + IP: endpoint.IP, + Port: port, + Token: endpoint.Token, + AuthToken: endpoint.AuthToken, + RawAuthToken: append([]byte(nil), endpoint.RawAuthToken...), + RawToken: append([]byte(nil), endpoint.RawToken...), + Key: endpoint.Key, + RelayID: endpoint.RelayID, + Name: name, + AuthTokenID: endpoint.AuthTokenID, + }) + } + return configs +} + +func ZeroRelayConfigs(configs []RelayConfig) { + for index := range configs { + zeroBytes(configs[index].RawToken) + zeroBytes(configs[index].RawAuthToken) + configs[index].RawToken = nil + configs[index].RawAuthToken = nil + configs[index].Token = "" + configs[index].AuthToken = "" + configs[index].Key = "" + configs[index].Name = "" + configs[index].AuthTokenID = "" + } +} + +// DisabledRelayTransport is the safe default until the Pion SCTP implementation +// is connected. It preserves callbacks and SSRC configuration but opens no socket. +type DisabledRelayTransport struct { + mu sync.RWMutex + ssrc uint32 + subscriptionSSRC uint32 + onConnected func(string, int) + onReceive func([]byte) +} + +func NewDisabledRelayTransport() *DisabledRelayTransport { return &DisabledRelayTransport{} } +func (d *DisabledRelayTransport) SetSSRC(ssrc uint32) { + d.mu.Lock() + d.ssrc = ssrc + d.mu.Unlock() +} +func (d *DisabledRelayTransport) SetSubscriptionSSRC(ssrc uint32) { + d.mu.Lock() + d.subscriptionSSRC = ssrc + d.mu.Unlock() +} +func (d *DisabledRelayTransport) SetOnConnected(fn func(string, int)) { + d.mu.Lock() + d.onConnected = fn + d.mu.Unlock() +} +func (d *DisabledRelayTransport) SetOnReceive(fn func([]byte)) { + d.mu.Lock() + d.onReceive = fn + d.mu.Unlock() +} +func (d *DisabledRelayTransport) ResendSubscriptions() {} +func (d *DisabledRelayTransport) ConfigureRelays([]RelayConfig) error { return ErrSCTPUnavailable } +func (d *DisabledRelayTransport) Broadcast([]byte) error { return ErrSCTPUnavailable } +func (d *DisabledRelayTransport) HasConnection() bool { return false } +func (d *DisabledRelayTransport) ConnectedCount() int { return 0 } +func (d *DisabledRelayTransport) Cleanup() {} + +var _ RelayTransport = (*DisabledRelayTransport)(nil) + +func zeroBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} From f75e666c30915ed1da2273621f29ec93a3010f7e Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:46:04 -0300 Subject: [PATCH 062/266] test(call): validate relay transport configuration --- pkg/call/voip/transport/relay_test.go | 90 +++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 pkg/call/voip/transport/relay_test.go diff --git a/pkg/call/voip/transport/relay_test.go b/pkg/call/voip/transport/relay_test.go new file mode 100644 index 00000000..da069f7a --- /dev/null +++ b/pkg/call/voip/transport/relay_test.go @@ -0,0 +1,90 @@ +package transport + +import ( + "errors" + "testing" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +func TestBuildRelayConfigsFiltersAndCopies(t *testing.T) { + token := []byte{1, 2, 3} + auth := []byte{4, 5, 6} + configs := BuildRelayConfigs([]core.RelayEndpoint{ + { + IP: "10.0.0.1", + Port: 0, + Protocol: 0, + Key: "relay-key", + RawToken: token, + RawAuthToken: auth, + AuthTokenID: "a", + }, + { + IP: "10.0.0.1", + Protocol: 0, + Key: "relay-key", + RawToken: []byte{9}, + AuthTokenID: "a", + }, + { + IP: "10.0.0.2", + Protocol: 1, + Key: "ignored", + RawToken: []byte{7}, + }, + { + IP: "10.0.0.3", + Protocol: 0, + RawToken: []byte{8}, + }, + }) + + if len(configs) != 1 { + t.Fatalf("config count = %d, want 1", len(configs)) + } + if configs[0].Port != core.WARelayPort || configs[0].Name != "10.0.0.1" { + t.Fatalf("unexpected defaults: %+v", configs[0]) + } + configs[0].RawToken[0] = 99 + configs[0].RawAuthToken[0] = 99 + if token[0] != 1 || auth[0] != 4 { + t.Fatal("relay config shares private buffers with protocol endpoint") + } +} + +func TestZeroRelayConfigsOverwritesBuffers(t *testing.T) { + token := []byte{1, 2} + auth := []byte{3, 4} + configs := []RelayConfig{{ + Token: "token", + AuthToken: "auth", + RawToken: token, + RawAuthToken: auth, + Key: "key", + }} + ZeroRelayConfigs(configs) + for _, buffer := range [][]byte{token, auth} { + for _, value := range buffer { + if value != 0 { + t.Fatalf("buffer was not overwritten: %v", buffer) + } + } + } + if configs[0].RawToken != nil || configs[0].RawAuthToken != nil || configs[0].Key != "" { + t.Fatal("relay config references were not cleared") + } +} + +func TestDisabledRelayTransportFailsClosed(t *testing.T) { + transport := NewDisabledRelayTransport() + if !errors.Is(transport.ConfigureRelays(nil), ErrSCTPUnavailable) { + t.Fatal("disabled transport must reject relay configuration") + } + if !errors.Is(transport.Broadcast([]byte{1}), ErrSCTPUnavailable) { + t.Fatal("disabled transport must reject media writes") + } + if transport.HasConnection() || transport.ConnectedCount() != 0 { + t.Fatal("disabled transport reported a connection") + } +} From 893e72040ec3eebc16d2fd66e14faa1a4a0103f7 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:48:13 -0300 Subject: [PATCH 063/266] feat(call): track strict private call negotiation state --- pkg/call/voip/incoming/registry.go | 81 ++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/pkg/call/voip/incoming/registry.go b/pkg/call/voip/incoming/registry.go index 84a41446..ec313362 100644 --- a/pkg/call/voip/incoming/registry.go +++ b/pkg/call/voip/incoming/registry.go @@ -9,6 +9,7 @@ import ( "sync" "time" + call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" @@ -26,6 +27,7 @@ type callMaterial struct { creator types.JID video bool relayData *core.RelayData + state *call_state.Info } type session struct { @@ -71,17 +73,25 @@ func (s *session) handleEvent(rawEvent interface{}) { prepareIncoming := s.prepareIncoming s.mu.RUnlock() if prepareIncoming { - // Decrypting the Signal payload and sending preaccept must not block - // the main Evolution event dispatcher. go s.prepareOffer(event) } case *events.CallAccept: + _ = s.transition(event.CallID, call_state.Transition{Type: call_state.TransitionRemoteAccepted}) s.captureRelays(event.CallID, event.Data) case *events.CallTransport: s.captureRelays(event.CallID, event.Data) case *events.CallReject: + _ = s.transition(event.CallID, call_state.Transition{ + Type: call_state.TransitionRemoteRejected, + Reason: core.EndCallReasonDeclined, + }) s.remove(event.CallID) case *events.CallTerminate: + reason := core.EndCallReason(event.Reason) + if reason == "" { + reason = core.EndCallReasonUnknown + } + _ = s.transition(event.CallID, call_state.Transition{Type: call_state.TransitionTerminated, Reason: reason}) s.remove(event.CallID) case *events.Disconnected: s.clear() @@ -128,19 +138,22 @@ func (s *session) prepareOffer(event *events.CallOffer) { return } + video := signaling.NodeContainsVideo(event.Data) + mediaType := core.CallMediaTypeAudio + if video { + mediaType = core.CallMediaTypeVideo + } material := &callMaterial{ callKey: append([]byte(nil), callKey...), peer: peer, creator: creator, - video: signaling.NodeContainsVideo(event.Data), + video: video, relayData: relayDataFromNode(event.Data), + state: call_state.NewIncoming(event.CallID, peer.String(), creator.String(), mediaType), } zeroBytes(callKey) s.store(event.CallID, material) - // WhatsApp expects preaccept before the user explicitly accepts the call. - // A send failure does not expose or discard the key; the accept endpoint will - // still return the concrete signaling error if the session is unhealthy. _ = socket.SendNode(ctx, signaling.BuildPreacceptStanza(peer, event.CallID, creator)) } @@ -148,12 +161,19 @@ func (s *session) storeOutgoing(callID string, callKey []byte, peer, creator typ if callID == "" || len(callKey) == 0 || peer.IsEmpty() || creator.IsEmpty() { return } + mediaType := core.CallMediaTypeAudio + if video { + mediaType = core.CallMediaTypeVideo + } + state := call_state.NewOutgoing(callID, peer.String(), creator.String(), mediaType) + _ = state.Apply(call_state.Transition{Type: call_state.TransitionOfferSent}) s.store(callID, &callMaterial{ callKey: append([]byte(nil), callKey...), peer: peer, creator: creator, video: video, relayData: core.CloneRelayData(relayData), + state: state, }) } @@ -183,6 +203,9 @@ func (s *session) accept(ctx context.Context, callID string) error { return fmt.Errorf("incoming call %s is not ready to accept", callID) } defer zeroMaterial(material) + if material.state == nil || !material.state.CanAccept() { + return fmt.Errorf("incoming call %s cannot be accepted in its current state", callID) + } s.mu.RLock() client := s.client @@ -207,7 +230,7 @@ func (s *session) accept(ctx context.Context, callID string) error { if err := socket.SendNode(ctx, node); err != nil { return fmt.Errorf("send call accept: %w", err) } - return nil + return s.transition(callID, call_state.Transition{Type: call_state.TransitionLocalAccepted}) } func (s *session) terminate(ctx context.Context, callID string) error { @@ -228,10 +251,24 @@ func (s *session) terminate(ctx context.Context, callID string) error { if err := wa.NewSocket(client).SendNode(ctx, node); err != nil { return fmt.Errorf("send call terminate: %w", err) } + _ = s.transition(callID, call_state.Transition{ + Type: call_state.TransitionTerminated, + Reason: core.EndCallReasonUserEnded, + }) s.remove(callID) return nil } +func (s *session) transition(callID string, transition call_state.Transition) error { + s.mu.Lock() + defer s.mu.Unlock() + material := s.materials[callID] + if material == nil || material.state == nil { + return fmt.Errorf("call %s has no private state", callID) + } + return material.state.Apply(transition) +} + func (s *session) store(callID string, material *callMaterial) { if callID == "" || material == nil { return @@ -241,6 +278,9 @@ func (s *session) store(callID string, material *callMaterial) { if material.relayData == nil { material.relayData = core.CloneRelayData(previous.relayData) } + if material.state == nil { + material.state = previous.state.Clone() + } zeroMaterial(previous) } s.materials[callID] = material @@ -260,6 +300,7 @@ func (s *session) copyMaterial(callID string) (*callMaterial, bool) { creator: material.creator, video: material.video, relayData: core.CloneRelayData(material.relayData), + state: material.state.Clone(), } s.mu.RUnlock() return copyValue, true @@ -277,6 +318,18 @@ func (s *session) relayData(callID string) (*core.RelayData, bool) { return data, true } +func (s *session) state(callID string) (*call_state.Info, bool) { + s.mu.RLock() + material := s.materials[callID] + if material == nil || material.state == nil { + s.mu.RUnlock() + return nil, false + } + state := material.state.Clone() + s.mu.RUnlock() + return state, true +} + func (s *session) remove(callID string) { s.mu.Lock() if material := s.materials[callID]; material != nil { @@ -345,6 +398,7 @@ func zeroMaterial(material *callMaterial) { material.peer = types.JID{} material.creator = types.JID{} material.relayData = nil + material.state = nil } func zeroBytes(value []byte) { @@ -363,9 +417,6 @@ func NewRegistry() *Registry { return &Registry{sessions: make(map[string]*session)} } -// Attach installs an isolated call handler on the same authenticated client used -// by messaging. Reattaching the same pointer is idempotent. prepareIncoming may -// be disabled for instances configured to reject calls automatically. func (r *Registry) Attach(instanceID string, client *whatsmeow.Client, prepareIncoming ...bool) { if instanceID == "" || client == nil { return @@ -417,6 +468,16 @@ func (r *Registry) RelayData(instanceID, callID string) (*core.RelayData, bool) return s.relayData(callID) } +func (r *Registry) State(instanceID, callID string) (*call_state.Info, bool) { + r.mu.RLock() + s := r.sessions[instanceID] + r.mu.RUnlock() + if s == nil { + return nil, false + } + return s.state(callID) +} + func (r *Registry) Accept(ctx context.Context, instanceID, callID string) error { r.mu.RLock() s := r.sessions[instanceID] From c6265640cf4d075e32c48a832130e5b2917142b3 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:48:37 -0300 Subject: [PATCH 064/266] test(call): integrate private negotiation with state machine --- .../voip/incoming/state_integration_test.go | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 pkg/call/voip/incoming/state_integration_test.go diff --git a/pkg/call/voip/incoming/state_integration_test.go b/pkg/call/voip/incoming/state_integration_test.go new file mode 100644 index 00000000..ae7ce807 --- /dev/null +++ b/pkg/call/voip/incoming/state_integration_test.go @@ -0,0 +1,72 @@ +package incoming + +import ( + "testing" + + call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "go.mau.fi/whatsmeow/types" +) + +func TestOutgoingMaterialStartsRingingAndAcceptsRemote(t *testing.T) { + s := newTestSession() + peer := types.NewJID("5511999999999", types.HiddenUserServer) + creator := types.NewJID("5511000000000", types.DefaultUserServer) + key := make([]byte, 32) + key[0] = 9 + + s.storeOutgoing("call-out", key, peer, creator, false, &core.RelayData{ + Endpoints: []core.RelayEndpoint{{IP: "10.0.0.1"}}, + }) + state, ok := s.state("call-out") + if !ok || state.StateData.State != core.CallStateRinging { + t.Fatalf("unexpected initial outgoing state: %+v", state) + } + if err := s.transition("call-out", call_state.Transition{Type: call_state.TransitionRemoteAccepted}); err != nil { + t.Fatalf("remote accept transition failed: %v", err) + } + state, _ = s.state("call-out") + if state.StateData.State != core.CallStateConnecting || state.StateData.AcceptedAt == nil { + t.Fatalf("unexpected accepted state: %+v", state.StateData) + } +} + +func TestIncomingMaterialRejectsRemoteAcceptTransition(t *testing.T) { + s := newTestSession() + state := call_state.NewIncoming( + "call-in", + "peer@s.whatsapp.net", + "peer@s.whatsapp.net", + core.CallMediaTypeAudio, + ) + s.store("call-in", &callMaterial{state: state}) + + if err := s.transition("call-in", call_state.Transition{Type: call_state.TransitionRemoteAccepted}); err == nil { + t.Fatal("incoming call unexpectedly accepted a remote-accepted transition") + } + stored, _ := s.state("call-in") + if stored.StateData.State != core.CallStateIncomingRinging { + t.Fatalf("invalid transition mutated state: %s", stored.StateData.State) + } +} + +func TestStateCopyIsIndependent(t *testing.T) { + s := newTestSession() + state := call_state.NewIncoming( + "call-in", + "peer@s.whatsapp.net", + "peer@s.whatsapp.net", + core.CallMediaTypeAudio, + ) + s.store("call-in", &callMaterial{state: state}) + + copyValue, ok := s.state("call-in") + if !ok { + t.Fatal("expected private state") + } + copyValue.StateData.State = core.CallStateEnded + stored, _ := s.state("call-in") + if stored.StateData.State != core.CallStateIncomingRinging { + t.Fatal("mutating state copy changed private state") + } +} From e9904bf58ef15a110e927df798e1e72aa7f99961 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:49:25 -0300 Subject: [PATCH 065/266] docs(call): document private relay state and transport boundary --- docs/wiki/guias-api/api-calls-experimental.md | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md index 82bbbc2c..80a29b1b 100644 --- a/docs/wiki/guias-api/api-calls-experimental.md +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -2,7 +2,7 @@ Esta branch adiciona a integração experimental WaCalls/AstraCalls ao Evolution Go. -> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. Os metadados de relay já são interpretados, mas o transporte de áudio/WebRTC/SRTP ainda não foi conectado. Não use em produção. +> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. Chaves e metadados de relay já são associados ao estado privado de cada chamada, mas o transporte de áudio/WebRTC/SRTP ainda não foi conectado. Não use em produção. Todas as rotas usam a autenticação normal da instância do Evolution. @@ -12,7 +12,7 @@ Todas as rotas usam a autenticação normal da instância do Evolution. GET /call/status ``` -Os monitores de chamada agora são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, os handlers anteriores são removidos e o material privado é apagado antes que o novo cliente seja registrado. Não é mais necessário chamar `/call/status` para ativar o monitoramento. +Os monitores de chamada são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, os handlers anteriores são removidos e o material privado é apagado antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento. Exemplo de resposta: @@ -26,7 +26,7 @@ Exemplo de resposta: Chaves de chamada, JIDs internos de dispositivos, tokens de relay e outros dados privados não fazem parte dessa resposta. Eles ficam somente em memória e são apagados quando a chamada termina, é rejeitada ou a sessão é desconectada. -Instâncias configuradas com rejeição automática continuam rastreando o estado público da chamada, mas não descriptografam a chave recebida nem enviam `preaccept`. +Instâncias configuradas com rejeição automática continuam rastreando o estado público da chamada. Elas não descriptografam ofertas recebidas nem enviam `preaccept`, mas continuam podendo iniciar chamadas e armazenar sua negociação privada de saída. ## Iniciar uma chamada @@ -43,7 +43,9 @@ apikey: INSTANCE_TOKEN } ``` -A resposta HTTP `201` contém o `id` da chamada e o estado inicial `ringing`. +A oferta é enviada como uma consulta do protocolo. Quando o ACK contém relays estruturados, a chave gerada, os participantes e os candidatos são copiados para o registro privado da chamada antes de o resultado transitório ser sobrescrito. + +A resposta HTTP `201` contém somente o estado público: ```json { @@ -87,7 +89,7 @@ DELETE /call/{callId} apikey: INSTANCE_TOKEN ``` -A rota envia `terminate` tanto para chamadas realizadas quanto para chamadas recebidas cujo material privado ainda está disponível em memória. Em seguida, o runtime muda o estado para `ended` e apaga a chave da chamada recebida. +A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove a chave e os dados privados da chamada. ## Rejeitar uma chamada recebida @@ -108,28 +110,49 @@ apikey: INSTANCE_TOKEN ## Estados rastreados +O snapshot público usa: + - `ringing` - `connecting` - `active` - `ended` - `failed` -O runtime escuta `CallOffer`, `CallOfferNotice`, `CallPreAccept`, `CallAccept`, `CallTransport`, `CallReject`, `CallTerminate`, `Disconnected` e `LoggedOut` no mesmo cliente utilizado pela mensageria. +Internamente, a negociação também usa uma máquina de estados estrita com: + +- `initiating` +- `ringing` +- `incoming_ringing` +- `connecting` +- `active` +- `on_hold` +- `ended` + +Transições inválidas, como marcar mídia conectada antes do aceite ou aceitar remotamente uma chamada recebida, são rejeitadas sem alterar o estado. -## Relay já interpretado +## Relay e transporte O módulo de sinalização reconhece os dois formatos encontrados nas respostas do WhatsApp: - candidatos com atributos diretos, como `ip`, `port`, `token`, `relay-id` e `c2r-rtt`; - respostas estruturadas `te2`, com tokens binários, `auth_token`, participantes, UUID, PIDs, HBH key, protocolo e endereço codificado em seis bytes. -Os candidatos são ordenados pelo menor RTT. Os tokens binários são copiados para buffers próprios e esses dados não fazem parte dos snapshots públicos. Nesta etapa eles ainda não são usados para abrir a conexão SCTP. +Os candidatos são ordenados pelo menor RTT e associados ao material privado pelo `callId`. Atualizações posteriores recebidas em `CallTransport` substituem os candidatos anteriores sem apagar a chave da chamada. + +O pacote `pkg/call/voip/transport` define o contrato usado pelo futuro gerenciador SCTP. Ele: + +- converte somente candidatos UDP utilizáveis; +- aplica a porta padrão do relay; +- remove duplicados; +- copia tokens binários para buffers independentes; +- oferece limpeza explícita desses buffers; +- usa um transportador desativado por padrão que falha de forma segura sem abrir sockets. ## Limitações atuais - sem áudio bidirecional; - sem WebRTC para navegador; -- sem conexão SCTP com os relays; +- o contrato SCTP existe, mas a implementação Pion ainda não está habilitada; - sem RTP ou SRTP; - aceitar a sinalização não estabelece o caminho de mídia; - as chaves ficam somente em memória e não sobrevivem a reinícios; From 352f2c8076ab604ffad23c1d0a423c38b6302376 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:52:11 -0300 Subject: [PATCH 066/266] feat(call): add relay subscription encoding --- pkg/call/voip/transport/subscriptions.go | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 pkg/call/voip/transport/subscriptions.go diff --git a/pkg/call/voip/transport/subscriptions.go b/pkg/call/voip/transport/subscriptions.go new file mode 100644 index 00000000..e01432f9 --- /dev/null +++ b/pkg/call/voip/transport/subscriptions.go @@ -0,0 +1,70 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package transport + +func encodeVarint(value uint64) []byte { + var output []byte + for value > 0x7f { + output = append(output, byte((value&0x7f)|0x80)) + value >>= 7 + } + return append(output, byte(value&0x7f)) +} + +func encodeProtobufVarintField(fieldNumber int, value uint64) []byte { + tag := encodeVarint(uint64(fieldNumber << 3)) + return append(tag, encodeVarint(value)...) +} + +func encodeProtobufLengthDelimited(fieldNumber int, data []byte) []byte { + tag := encodeVarint(uint64((fieldNumber << 3) | 2)) + output := append(tag, encodeVarint(uint64(len(data)))...) + return append(output, data...) +} + +// BuildSenderSubscriptions creates the WhatsApp relay subscription protobuf +// attached to STUN binding requests. +func BuildSenderSubscriptions(ssrc uint32) []byte { + inner := concat( + encodeProtobufVarintField(3, uint64(ssrc)), + encodeProtobufVarintField(5, 0), + encodeProtobufVarintField(6, 0), + ) + return encodeProtobufLengthDelimited(1, inner) +} + +// BuildSSRCSubscriptionList creates the allocation payload for local and remote +// media SSRCs. Zero SSRC values are omitted. +func BuildSSRCSubscriptionList(selfSSRCs, peerSSRCs []uint32, selfPID, peerPID int) []byte { + var entries [][]byte + for _, ssrc := range selfSSRCs { + if ssrc == 0 { + continue + } + inner := concat( + encodeProtobufVarintField(1, uint64(selfPID)), + encodeProtobufVarintField(2, 1), + encodeProtobufVarintField(3, uint64(ssrc)), + ) + entries = append(entries, encodeProtobufLengthDelimited(1, inner)) + } + for _, ssrc := range peerSSRCs { + if ssrc == 0 { + continue + } + inner := concat( + encodeProtobufVarintField(1, uint64(peerPID)), + encodeProtobufVarintField(2, 1), + encodeProtobufVarintField(3, uint64(ssrc)), + ) + entries = append(entries, encodeProtobufLengthDelimited(1, inner)) + } + return concat(entries...) +} + +func concat(parts ...[]byte) []byte { + var output []byte + for _, part := range parts { + output = append(output, part...) + } + return output +} From 3a332ec4fb2ce2bc6a0198627e709a31f5b83bdc Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:52:48 -0300 Subject: [PATCH 067/266] feat(call): add STUN relay framing --- pkg/call/voip/transport/stun.go | 294 ++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 pkg/call/voip/transport/stun.go diff --git a/pkg/call/voip/transport/stun.go b/pkg/call/voip/transport/stun.go new file mode 100644 index 00000000..b9e4d134 --- /dev/null +++ b/pkg/call/voip/transport/stun.go @@ -0,0 +1,294 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package transport + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/binary" + "encoding/hex" + "fmt" + "hash/crc32" + "net" + "strings" +) + +const ( + stunMagicCookie = 0x2112a442 + stunFingerprintXOR = 0x5354554e + stunBindingRequest = 0x0001 + stunAllocateRequest = 0x0003 + whatsAppPing = 0x0801 + + attrUsername = 0x0006 + attrMessageIntegrity = 0x0008 + attrXORRelayedAddress = 0x0016 + attrPriority = 0x0024 + attrSenderSubscriptions = 0x4000 + attrSSRCList = 0x4024 + attrICEControlling = 0x802a + attrFingerprint = 0x8028 + + defaultICEPriority = 16_777_215 +) + +func generateTransactionID() ([]byte, error) { + id := make([]byte, 12) + if _, err := rand.Read(id); err != nil { + return nil, fmt.Errorf("generate STUN transaction ID: %w", err) + } + return id, nil +} + +func encodeAttribute(attributeType int, data []byte) []byte { + header := make([]byte, 4) + binary.BigEndian.PutUint16(header[0:], uint16(attributeType)) + binary.BigEndian.PutUint16(header[2:], uint16(len(data))) + padding := (4 - (len(data) % 4)) % 4 + output := append(header, data...) + return append(output, make([]byte, padding)...) +} + +func buildSTUNMessage(messageType int, attributes, transactionID, integrityKey []byte, includeFingerprint bool) []byte { + attributesData := append([]byte(nil), attributes...) + + if len(integrityKey) > 0 { + messageLengthForHMAC := len(attributesData) + 24 + hmacHeader := make([]byte, 20) + binary.BigEndian.PutUint16(hmacHeader[0:], uint16(messageType)) + binary.BigEndian.PutUint16(hmacHeader[2:], uint16(messageLengthForHMAC)) + binary.BigEndian.PutUint32(hmacHeader[4:], stunMagicCookie) + copy(hmacHeader[8:], transactionID) + + mac := hmac.New(sha1.New, integrityKey) + _, _ = mac.Write(hmacHeader) + _, _ = mac.Write(attributesData) + attributesData = append(attributesData, encodeAttribute(attrMessageIntegrity, mac.Sum(nil))...) + } + + if includeFingerprint { + messageLengthForCRC := len(attributesData) + 8 + crcHeader := make([]byte, 20) + binary.BigEndian.PutUint16(crcHeader[0:], uint16(messageType)) + binary.BigEndian.PutUint16(crcHeader[2:], uint16(messageLengthForCRC)) + binary.BigEndian.PutUint32(crcHeader[4:], stunMagicCookie) + copy(crcHeader[8:], transactionID) + + crcInput := append(append([]byte(nil), crcHeader...), attributesData...) + fingerprint := crc32.ChecksumIEEE(crcInput) ^ stunFingerprintXOR + fingerprintBuffer := make([]byte, 4) + binary.BigEndian.PutUint32(fingerprintBuffer, fingerprint) + attributesData = append(attributesData, encodeAttribute(attrFingerprint, fingerprintBuffer)...) + } + + header := make([]byte, 20) + binary.BigEndian.PutUint16(header[0:], uint16(messageType)) + binary.BigEndian.PutUint16(header[2:], uint16(len(attributesData))) + binary.BigEndian.PutUint32(header[4:], stunMagicCookie) + copy(header[8:], transactionID) + return append(header, attributesData...) +} + +func encodeXORRelayedAddress(ip string, port int) ([]byte, error) { + parsedIP := net.ParseIP(ip).To4() + if parsedIP == nil { + return nil, fmt.Errorf("relay IP %q is not IPv4", ip) + } + if port <= 0 || port > 65535 { + return nil, fmt.Errorf("relay port %d is invalid", port) + } + + data := make([]byte, 8) + data[1] = 0x01 + binary.BigEndian.PutUint16(data[2:], uint16(port)^uint16(stunMagicCookie>>16)) + ipNumber := binary.BigEndian.Uint32(parsedIP) + binary.BigEndian.PutUint32(data[4:], ipNumber^stunMagicCookie) + return data, nil +} + +// BuildAllocateForRelay builds the WhatsApp relay allocation request. +func BuildAllocateForRelay(senderSubscriptions, ssrcList, hmacKey []byte, relayIP string, relayPort int) ([]byte, error) { + transactionID, err := generateTransactionID() + if err != nil { + return nil, err + } + parts := [][]byte{ + encodeAttribute(attrSenderSubscriptions, senderSubscriptions), + encodeAttribute(attrSSRCList, ssrcList), + } + if relayIP != "" && relayPort != 0 { + address, addressErr := encodeXORRelayedAddress(relayIP, relayPort) + if addressErr != nil { + return nil, addressErr + } + parts = append(parts, encodeAttribute(attrXORRelayedAddress, address)) + } + return buildSTUNMessage(stunAllocateRequest, concat(parts...), transactionID, hmacKey, false), nil +} + +// BuildBindingRequestWithSubscriptions creates a STUN binding request carrying +// WhatsApp sender subscriptions. +func BuildBindingRequestWithSubscriptions(username, hmacKey, senderSubscriptions []byte, includeICEControlling, includeFingerprint bool) ([]byte, error) { + transactionID, err := generateTransactionID() + if err != nil { + return nil, err + } + var parts [][]byte + if len(username) > 0 { + parts = append(parts, encodeAttribute(attrUsername, username)) + } + priority := make([]byte, 4) + binary.BigEndian.PutUint32(priority, defaultICEPriority) + parts = append(parts, encodeAttribute(attrPriority, priority)) + if includeICEControlling { + tieBreaker := make([]byte, 8) + if _, err = rand.Read(tieBreaker); err != nil { + return nil, fmt.Errorf("generate ICE tie breaker: %w", err) + } + parts = append(parts, encodeAttribute(attrICEControlling, tieBreaker)) + } + if len(senderSubscriptions) > 0 { + parts = append(parts, encodeAttribute(attrSenderSubscriptions, senderSubscriptions)) + } + return buildSTUNMessage(stunBindingRequest, concat(parts...), transactionID, hmacKey, includeFingerprint), nil +} + +// BuildWhatsAppPing returns the proprietary keepalive frame used by relays. +func BuildWhatsAppPing() ([]byte, error) { + transactionID, err := generateTransactionID() + if err != nil { + return nil, err + } + header := make([]byte, 20) + binary.BigEndian.PutUint16(header[0:], whatsAppPing) + binary.BigEndian.PutUint32(header[4:], stunMagicCookie) + copy(header[8:], transactionID) + return header, nil +} + +func IsSTUNPacket(data []byte) bool { return len(data) >= 2 && data[0]&0xc0 == 0 } +func IsRTPPacket(data []byte) bool { return len(data) >= 2 && data[0]&0xc0 == 0x80 } + +type STUNAttribute struct { + Type int + TypeName string + Length int + Data []byte +} + +type STUNResponseInfo struct { + RawType int + Method string + Class string + IsSuccess bool + IsError bool + ErrorCode int + ErrorReason string + StableRoutingConnID uint64 + TransactionID string + Length int + Attributes []STUNAttribute +} + +var stunAttributeNames = map[int]string{ + 0x0001: "MAPPED-ADDRESS", 0x0006: "USERNAME", 0x0008: "MESSAGE-INTEGRITY", + 0x0009: "ERROR-CODE", 0x0016: "XOR-RELAYED-ADDRESS", 0x0020: "XOR-MAPPED-ADDRESS", + 0x0024: "PRIORITY", 0x4000: "SENDER-SUBSCRIPTIONS", 0x4001: "RECEIVER-SUBSCRIPTION", + 0x4002: "SUBSCRIPTION-ACK", 0x4024: "SSRC-LIST", 0x4033: "STABLE-ROUTING-CONN-ID", + 0x8028: "FINGERPRINT", 0x8029: "ICE-CONTROLLED", 0x802a: "ICE-CONTROLLING", +} + +func ParseSTUNResponse(data []byte) *STUNResponseInfo { + if len(data) < 20 || binary.BigEndian.Uint32(data[4:]) != stunMagicCookie { + return nil + } + + rawType := int(binary.BigEndian.Uint16(data[0:])) + messageLength := int(binary.BigEndian.Uint16(data[2:])) + if 20+messageLength > len(data) { + return nil + } + classNumber := (((rawType >> 8) & 0x1) << 1) | ((rawType >> 4) & 0x1) + classes := []string{"request", "indication", "success", "error"} + class := "unknown" + if classNumber < len(classes) { + class = classes[classNumber] + } + methodBits := ((rawType & 0x3e00) >> 2) | ((rawType & 0x00e0) >> 1) | (rawType & 0x000f) + method := map[int]string{0x001: "binding", 0x003: "allocate", 0x004: "refresh", 0x006: "send", 0x007: "data", 0x008: "create-permission", 0x009: "channel-bind"}[methodBits] + if method == "" { + method = "unknown" + } + if rawType == 0x0801 { + method = "wa-ping" + } else if rawType == 0x0802 { + method = "wa-pong" + } + + info := &STUNResponseInfo{ + RawType: rawType, + Method: method, + Class: class, + IsSuccess: class == "success", + IsError: class == "error", + TransactionID: hex.EncodeToString(data[8:20]), + Length: len(data), + } + + for offset := 20; offset+4 <= 20+messageLength; { + attributeType := int(binary.BigEndian.Uint16(data[offset:])) + attributeLength := int(binary.BigEndian.Uint16(data[offset+2:])) + attributeEnd := offset + 4 + attributeLength + if attributeEnd > len(data) || attributeEnd > 20+messageLength { + return nil + } + attributeData := append([]byte(nil), data[offset+4:attributeEnd]...) + name := stunAttributeNames[attributeType] + if name == "" { + name = fmt.Sprintf("0x%04x", attributeType) + } + info.Attributes = append(info.Attributes, STUNAttribute{Type: attributeType, TypeName: name, Length: attributeLength, Data: attributeData}) + if attributeType == 0x0009 && attributeLength >= 4 { + info.ErrorCode = int(attributeData[2]&0x07)*100 + int(attributeData[3]) + if attributeLength > 4 { + info.ErrorReason = string(attributeData[4:]) + } + } + if attributeType == 0x4033 && class == "success" && attributeLength == 8 { + info.StableRoutingConnID = binary.BigEndian.Uint64(attributeData) + } + offset = attributeEnd + ((4 - (attributeLength % 4)) % 4) + } + return info +} + +func ClassifyPacket(data []byte) string { + if len(data) < 2 { + return fmt.Sprintf("tiny(%dB)", len(data)) + } + switch (data[0] & 0xc0) >> 6 { + case 0: + if info := ParseSTUNResponse(data); info != nil { + result := fmt.Sprintf("STUN %s %s (0x%04x, %dB)", info.Method, info.Class, info.RawType, info.Length) + if len(info.Attributes) > 0 { + names := make([]string, len(info.Attributes)) + for index, attribute := range info.Attributes { + names[index] = attribute.TypeName + } + result += " [" + strings.Join(names, ", ") + "]" + } + return result + } + return fmt.Sprintf("STUN? 0x%x (%dB)", int(data[0])<<8|int(data[1]), len(data)) + case 2: + sequence := 0 + if len(data) >= 4 { + sequence = int(binary.BigEndian.Uint16(data[2:4])) + } + return fmt.Sprintf("RTP/SRTP PT=%d M=%d seq=%d (%dB)", data[1]&0x7f, data[1]>>7, sequence, len(data)) + case 1: + return fmt.Sprintf("DTLS? 0x%x (%dB)", data[0], len(data)) + default: + return fmt.Sprintf("unknown 0x%x (%dB)", data[0], len(data)) + } +} From 648d92e2bb192c185076caaab707f2359926d03d Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:53:09 -0300 Subject: [PATCH 068/266] test(call): cover relay framing foundation --- pkg/call/voip/transport/foundation_test.go | 131 +++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 pkg/call/voip/transport/foundation_test.go diff --git a/pkg/call/voip/transport/foundation_test.go b/pkg/call/voip/transport/foundation_test.go new file mode 100644 index 00000000..1b983299 --- /dev/null +++ b/pkg/call/voip/transport/foundation_test.go @@ -0,0 +1,131 @@ +package transport + +import ( + "bytes" + "encoding/binary" + "strings" + "testing" +) + +func TestVarintEncoding(t *testing.T) { + cases := []struct { + input uint64 + want []byte + }{ + {0, []byte{0x00}}, + {127, []byte{0x7f}}, + {128, []byte{0x80, 0x01}}, + {300, []byte{0xac, 0x02}}, + } + for _, testCase := range cases { + if got := encodeVarint(testCase.input); !bytes.Equal(got, testCase.want) { + t.Fatalf("encodeVarint(%d)=%x, want %x", testCase.input, got, testCase.want) + } + } +} + +func TestSenderSubscriptions(t *testing.T) { + inner := []byte{0x18, 0x10, 0x28, 0x00, 0x30, 0x00} + want := append([]byte{0x0a, byte(len(inner))}, inner...) + if got := BuildSenderSubscriptions(0x10); !bytes.Equal(got, want) { + t.Fatalf("sender subscriptions mismatch: got=%x want=%x", got, want) + } +} + +func TestSSRCSubscriptionListOmitsZeroValues(t *testing.T) { + withoutZero := BuildSSRCSubscriptionList([]uint32{100}, []uint32{200}, 1, 2) + withZero := BuildSSRCSubscriptionList([]uint32{0, 100}, []uint32{200, 0}, 1, 2) + if !bytes.Equal(withoutZero, withZero) { + t.Fatalf("zero SSRC changed payload: without=%x with=%x", withoutZero, withZero) + } +} + +func TestSTUNBindingFingerprint(t *testing.T) { + subscriptions := BuildSenderSubscriptions(0x12345678) + message, err := BuildBindingRequestWithSubscriptions(nil, nil, subscriptions, true, true) + if err != nil { + t.Fatal(err) + } + if binary.BigEndian.Uint32(message[4:8]) != stunMagicCookie { + t.Fatal("missing STUN magic cookie") + } + info := ParseSTUNResponse(message) + if info == nil || info.Method != "binding" || info.Class != "request" { + t.Fatalf("unexpected parsed binding request: %#v", info) + } + last := info.Attributes[len(info.Attributes)-1] + if last.TypeName != "FINGERPRINT" { + t.Fatalf("expected fingerprint last, got %s", last.TypeName) + } + fingerprintStart := len(message) - 8 + want := crc32Checksum(message[:fingerprintStart]) ^ stunFingerprintXOR + got := binary.BigEndian.Uint32(message[len(message)-4:]) + if got != want { + t.Fatalf("fingerprint mismatch: got=%08x want=%08x", got, want) + } +} + +func TestAllocateRequestIncludesRelayAddress(t *testing.T) { + message, err := BuildAllocateForRelay([]byte{1}, []byte{2}, []byte("secret"), "127.0.0.1", 3480) + if err != nil { + t.Fatal(err) + } + info := ParseSTUNResponse(message) + if info == nil || info.Method != "allocate" { + t.Fatalf("unexpected allocation request: %#v", info) + } + var found bool + for _, attribute := range info.Attributes { + if attribute.TypeName == "XOR-RELAYED-ADDRESS" { + found = true + } + } + if !found { + t.Fatal("allocation request did not contain relay address") + } +} + +func TestAllocateRequestRejectsInvalidAddress(t *testing.T) { + if _, err := BuildAllocateForRelay(nil, nil, nil, "not-an-ip", 3480); err == nil { + t.Fatal("expected invalid IP error") + } + if _, err := BuildAllocateForRelay(nil, nil, nil, "127.0.0.1", 70000); err == nil { + t.Fatal("expected invalid port error") + } +} + +func TestPacketClassification(t *testing.T) { + ping, err := BuildWhatsAppPing() + if err != nil { + t.Fatal(err) + } + if !IsSTUNPacket(ping) || IsRTPPacket(ping) { + t.Fatalf("unexpected ping classification: %s", ClassifyPacket(ping)) + } + if classification := ClassifyPacket(ping); !strings.Contains(classification, "wa-ping") { + t.Fatalf("unexpected ping description: %s", classification) + } + + rtp := []byte{0x80, 120, 0x01, 0x02} + if !IsRTPPacket(rtp) || IsSTUNPacket(rtp) { + t.Fatalf("unexpected RTP classification: %s", ClassifyPacket(rtp)) + } + if classification := ClassifyPacket(rtp); !strings.Contains(classification, "seq=258") { + t.Fatalf("unexpected RTP description: %s", classification) + } +} + +func TestParseSTUNRejectsTruncatedMessage(t *testing.T) { + message, err := BuildBindingRequestWithSubscriptions(nil, nil, nil, false, false) + if err != nil { + t.Fatal(err) + } + message = message[:len(message)-1] + if ParseSTUNResponse(message) != nil { + t.Fatal("expected truncated STUN message to be rejected") + } +} + +func crc32Checksum(value []byte) uint32 { + return crc32.ChecksumIEEE(value) +} From 1915ba4e566f9b9f44fa47f543bb11e99b09a524 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:53:33 -0300 Subject: [PATCH 069/266] fix(call): import CRC32 in relay tests --- pkg/call/voip/transport/foundation_test.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pkg/call/voip/transport/foundation_test.go b/pkg/call/voip/transport/foundation_test.go index 1b983299..434acb10 100644 --- a/pkg/call/voip/transport/foundation_test.go +++ b/pkg/call/voip/transport/foundation_test.go @@ -3,6 +3,7 @@ package transport import ( "bytes" "encoding/binary" + "hash/crc32" "strings" "testing" ) @@ -58,7 +59,7 @@ func TestSTUNBindingFingerprint(t *testing.T) { t.Fatalf("expected fingerprint last, got %s", last.TypeName) } fingerprintStart := len(message) - 8 - want := crc32Checksum(message[:fingerprintStart]) ^ stunFingerprintXOR + want := crc32.ChecksumIEEE(message[:fingerprintStart]) ^ stunFingerprintXOR got := binary.BigEndian.Uint32(message[len(message)-4:]) if got != want { t.Fatalf("fingerprint mismatch: got=%08x want=%08x", got, want) @@ -125,7 +126,3 @@ func TestParseSTUNRejectsTruncatedMessage(t *testing.T) { t.Fatal("expected truncated STUN message to be rejected") } } - -func crc32Checksum(value []byte) uint32 { - return crc32.ChecksumIEEE(value) -} From 082b63cdaa5614c1972153f8c18fa50c45af05b4 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:54:40 -0300 Subject: [PATCH 070/266] feat(call): add WhatsApp relay DTLS fingerprint --- pkg/call/voip/core/relay_constants.go | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 pkg/call/voip/core/relay_constants.go diff --git a/pkg/call/voip/core/relay_constants.go b/pkg/call/voip/core/relay_constants.go new file mode 100644 index 00000000..ce346d25 --- /dev/null +++ b/pkg/call/voip/core/relay_constants.go @@ -0,0 +1,6 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package core + +// WADTLSFingerprint is the fingerprint advertised by WhatsApp media relays. +// It is used only by the experimental Pion SCTP transport. +const WADTLSFingerprint = "sha-256 F9:CA:0C:98:A3:CC:71:D6:42:CE:5A:E2:53:D2:15:20:D3:1B:BA:D8:57:A4:F0:AF:BE:0B:FB:F3:6B:0C:A0:68" From ab69445ce65ad050334032823f7c13eedee84056 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:54:50 -0300 Subject: [PATCH 071/266] feat(call): add default relay transport factory --- pkg/call/voip/transport/factory_default.go | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 pkg/call/voip/transport/factory_default.go diff --git a/pkg/call/voip/transport/factory_default.go b/pkg/call/voip/transport/factory_default.go new file mode 100644 index 00000000..f7e326ea --- /dev/null +++ b/pkg/call/voip/transport/factory_default.go @@ -0,0 +1,11 @@ +//go:build !voip_pion + +package transport + +import "log/slog" + +// NewRelayTransport returns the safe no-network implementation unless the +// experimental voip_pion build tag is explicitly enabled. +func NewRelayTransport(_ *slog.Logger) RelayTransport { + return NewDisabledRelayTransport() +} From 845abd859e6af322ec7cf6ab4d1629bc006d2f5b Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:55:51 -0300 Subject: [PATCH 072/266] feat(call): add experimental Pion SCTP relay transport --- pkg/call/voip/transport/pion_relay.go | 574 ++++++++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 pkg/call/voip/transport/pion_relay.go diff --git a/pkg/call/voip/transport/pion_relay.go b/pkg/call/voip/transport/pion_relay.go new file mode 100644 index 00000000..b990c0b6 --- /dev/null +++ b/pkg/call/voip/transport/pion_relay.go @@ -0,0 +1,574 @@ +//go:build voip_pion + +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package transport + +import ( + "errors" + "fmt" + "log/slog" + "regexp" + "sync" + "time" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "github.com/pion/webrtc/v4" +) + +const ( + relayConnectionTimeout = 20 * time.Second + relayKeepaliveInterval = 1100 * time.Millisecond +) + +type relayConnectionState uint8 + +const ( + relayStateConnecting relayConnectionState = iota + relayStateOpen + relayStateClosed + relayStateFailed +) + +type pionRelayConnection struct { + mu sync.RWMutex + state relayConnectionState + pc *webrtc.PeerConnection + channel *webrtc.DataChannel + id string + info RelayConfig + localUfrag string + keepalive *time.Ticker + stopCh chan struct{} + stopOnce sync.Once +} + +func (c *pionRelayConnection) isOpen() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.state == relayStateOpen && c.channel != nil +} + +func (c *pionRelayConnection) setOpen() bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.state != relayStateConnecting { + return false + } + c.state = relayStateOpen + return true +} + +func (c *pionRelayConnection) setTerminal(state relayConnectionState) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.state == relayStateClosed || c.state == relayStateFailed { + return false + } + c.state = state + return true +} + +// PionRelayTransport opens WhatsApp relay DataChannels when the voip_pion build +// tag is enabled. The default build never includes this implementation. +type PionRelayTransport struct { + mu sync.RWMutex + connections map[string]*pionRelayConnection + log *slog.Logger + ssrc uint32 + subscriptionSSRC uint32 + onConnected func(ip string, port int) + onReceive func(data []byte) +} + +func NewPionRelayTransport(log *slog.Logger) *PionRelayTransport { + if log == nil { + log = slog.Default() + } + return &PionRelayTransport{ + connections: make(map[string]*pionRelayConnection), + log: log, + } +} + +// NewRelayTransport selects the real transport only in a voip_pion build. +func NewRelayTransport(log *slog.Logger) RelayTransport { + return NewPionRelayTransport(log) +} + +func (m *PionRelayTransport) SetSSRC(ssrc uint32) { + m.mu.Lock() + m.ssrc = ssrc + m.mu.Unlock() +} + +func (m *PionRelayTransport) SetSubscriptionSSRC(ssrc uint32) { + m.mu.Lock() + m.subscriptionSSRC = ssrc + m.mu.Unlock() +} + +func (m *PionRelayTransport) SetOnConnected(callback func(ip string, port int)) { + m.mu.Lock() + m.onConnected = callback + m.mu.Unlock() +} + +func (m *PionRelayTransport) SetOnReceive(callback func(data []byte)) { + m.mu.Lock() + m.onReceive = callback + m.mu.Unlock() +} + +func (m *PionRelayTransport) ResendSubscriptions() { + for _, connection := range m.connectionSnapshot() { + if connection.isOpen() { + m.sendSTUNRegistration(connection) + } + } +} + +func relayConnectionID(ip string, port int, authTokenID string) string { + identity := fmt.Sprintf("%s:%d", ip, port) + if authTokenID != "" { + identity += "#" + authTokenID + } + return identity +} + +func (m *PionRelayTransport) ConfigureRelays(relays []RelayConfig) error { + if len(relays) == 0 { + return fmt.Errorf("no relay configurations supplied") + } + + var setupErrors []error + for _, relay := range relays { + config := cloneRelayConfig(relay) + if config.Port == 0 { + config.Port = core.WARelayPort + } + if config.IP == "" || config.Key == "" || len(config.RawToken) == 0 { + zeroRelayConfig(&config) + setupErrors = append(setupErrors, fmt.Errorf("relay configuration is missing IP, key or token")) + continue + } + + identity := relayConnectionID(config.IP, config.Port, config.AuthTokenID) + connection := &pionRelayConnection{ + state: relayStateConnecting, + id: identity, + info: config, + stopCh: make(chan struct{}), + } + + m.mu.Lock() + if _, exists := m.connections[identity]; exists { + m.mu.Unlock() + zeroRelayConfig(&config) + continue + } + m.connections[identity] = connection + m.mu.Unlock() + + if err := m.connectToRelay(connection); err != nil { + m.failConnection(connection) + setupErrors = append(setupErrors, fmt.Errorf("configure relay %s: %w", identity, err)) + } + } + return errors.Join(setupErrors...) +} + +func (m *PionRelayTransport) connectToRelay(connection *pionRelayConnection) error { + info := connection.info + m.log.Info("WhatsApp relay connecting", "id", connection.id, "name", info.Name) + + peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + return fmt.Errorf("create peer connection: %w", err) + } + connection.mu.Lock() + connection.pc = peerConnection + connection.mu.Unlock() + + peerConnection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { + m.log.Debug("WhatsApp relay ICE state", "id", connection.id, "state", state.String()) + switch state { + case webrtc.ICEConnectionStateFailed, webrtc.ICEConnectionStateDisconnected: + m.failConnection(connection) + case webrtc.ICEConnectionStateClosed: + m.closeConnection(connection.id) + } + }) + + ordered := false + channel, err := peerConnection.CreateDataChannel("wa-web-call", &webrtc.DataChannelInit{Ordered: &ordered}) + if err != nil { + return fmt.Errorf("create relay data channel: %w", err) + } + connection.mu.Lock() + connection.channel = channel + connection.mu.Unlock() + + channel.OnOpen(func() { + if !connection.setOpen() { + return + } + m.sendSTUNRegistration(connection) + m.startKeepalive(connection) + m.mu.RLock() + callback := m.onConnected + m.mu.RUnlock() + if callback != nil { + callback(info.IP, info.Port) + } + }) + channel.OnClose(func() { m.closeConnection(connection.id) }) + channel.OnMessage(func(message webrtc.DataChannelMessage) { + m.mu.RLock() + callback := m.onReceive + m.mu.RUnlock() + if callback != nil { + callback(append([]byte(nil), message.Data...)) + } + }) + + offer, err := peerConnection.CreateOffer(nil) + if err != nil { + return fmt.Errorf("create relay SDP offer: %w", err) + } + if err = peerConnection.SetLocalDescription(offer); err != nil { + return fmt.Errorf("set relay local description: %w", err) + } + connection.mu.Lock() + connection.localUfrag = extractFirst(relayUfragPattern, offer.SDP) + connection.mu.Unlock() + + answer := modifySDPForRelay(offer.SDP, info) + if err = peerConnection.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: answer}); err != nil { + return fmt.Errorf("set relay remote description: %w", err) + } + + go func() { + timer := time.NewTimer(relayConnectionTimeout) + defer timer.Stop() + select { + case <-timer.C: + connection.mu.RLock() + connecting := connection.state == relayStateConnecting + connection.mu.RUnlock() + if connecting { + m.failConnection(connection) + } + case <-connection.stopCh: + } + }() + return nil +} + +var ( + relaySetupPattern = regexp.MustCompile(`a=setup:actpass`) + relayUfragLinePattern = regexp.MustCompile(`a=ice-ufrag:[^\r\n]+`) + relayPasswordPattern = regexp.MustCompile(`a=ice-pwd:[^\r\n]+`) + relayFingerprintPattern = regexp.MustCompile(`a=fingerprint:[^\r\n]+`) + relayMaxMessagePattern = regexp.MustCompile(`a=max-message-size:[^\r\n]+`) + relayICEOptionsPattern = regexp.MustCompile(`a=ice-options:[^\r\n]+\r?\n`) + relayCandidatePattern = regexp.MustCompile(`a=candidate:[^\r\n]+\r?\n`) + relayEndCandidatePattern = regexp.MustCompile(`a=end-of-candidates\r?\n?`) + relayUfragPattern = regexp.MustCompile(`a=ice-ufrag:([^\r\n]+)`) +) + +func modifySDPForRelay(sdp string, info RelayConfig) string { + output := relaySetupPattern.ReplaceAllString(sdp, "a=setup:passive") + iceUfrag := info.AuthToken + if iceUfrag == "" { + iceUfrag = info.Token + } + output = relayUfragLinePattern.ReplaceAllString(output, "a=ice-ufrag:"+iceUfrag) + output = relayPasswordPattern.ReplaceAllString(output, "a=ice-pwd:"+info.Key) + output = relayFingerprintPattern.ReplaceAllString(output, "a=fingerprint:"+core.WADTLSFingerprint) + output = relayMaxMessagePattern.ReplaceAllString(output, "a=max-message-size:1500") + output = relayICEOptionsPattern.ReplaceAllString(output, "") + output = relayCandidatePattern.ReplaceAllString(output, "") + output = relayEndCandidatePattern.ReplaceAllString(output, "") + candidate := fmt.Sprintf("a=candidate:2 1 udp 2122262783 %s %d typ host generation 0 network-cost 5", info.IP, info.Port) + return output + candidate + "\r\na=end-of-candidates\r\n" +} + +func extractFirst(pattern *regexp.Regexp, value string) string { + matches := pattern.FindStringSubmatch(value) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +func (m *PionRelayTransport) sendSTUNRegistration(connection *pionRelayConnection) { + connection.mu.RLock() + info := cloneRelayConfig(connection.info) + localUfrag := connection.localUfrag + open := connection.state == relayStateOpen && connection.channel != nil + connection.mu.RUnlock() + defer zeroRelayConfig(&info) + if !open { + return + } + + remoteUfrag := info.AuthToken + if remoteUfrag == "" { + remoteUfrag = info.Token + } + if remoteUfrag == "" { + return + } + + m.mu.RLock() + selfSSRC := m.ssrc + peerSSRC := m.subscriptionSSRC + m.mu.RUnlock() + subscriptionSSRC := peerSSRC + if subscriptionSSRC == 0 { + subscriptionSSRC = selfSSRC + } + if subscriptionSSRC == 0 { + return + } + + subscriptions := BuildSenderSubscriptions(subscriptionSSRC) + hmacKey := []byte(info.Key) + sendBinding := func(username, key []byte, controlling, fingerprint bool) { + message, err := BuildBindingRequestWithSubscriptions(username, key, subscriptions, controlling, fingerprint) + if err == nil { + _ = m.sendRaw(connection, message) + } + } + if localUfrag != "" { + sendBinding([]byte(remoteUfrag+":"+localUfrag), hmacKey, true, true) + } + if info.Token != "" && info.Token != remoteUfrag && localUfrag != "" { + sendBinding([]byte(info.Token+":"+localUfrag), hmacKey, true, true) + } + sendBinding(nil, nil, false, false) + + if len(info.RawToken) > 0 { + var peerSSRCs []uint32 + if peerSSRC != 0 { + peerSSRCs = []uint32{peerSSRC} + } + ssrcList := BuildSSRCSubscriptionList([]uint32{selfSSRC}, peerSSRCs, 0, 0) + allocation, err := BuildAllocateForRelay(info.RawToken, ssrcList, hmacKey, info.IP, info.Port) + if err == nil { + _ = m.sendRaw(connection, allocation) + } + } + + for _, delay := range []time.Duration{50, 150, 500, 3000} { + delay := delay * time.Millisecond + go func() { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-timer.C: + if connection.isOpen() { + m.sendSTUNRegistrationOnce(connection) + } + case <-connection.stopCh: + } + }() + } +} + +func (m *PionRelayTransport) sendSTUNRegistrationOnce(connection *pionRelayConnection) { + connection.mu.RLock() + info := cloneRelayConfig(connection.info) + localUfrag := connection.localUfrag + connection.mu.RUnlock() + defer zeroRelayConfig(&info) + + m.mu.RLock() + selfSSRC := m.ssrc + peerSSRC := m.subscriptionSSRC + m.mu.RUnlock() + subscriptionSSRC := peerSSRC + if subscriptionSSRC == 0 { + subscriptionSSRC = selfSSRC + } + if subscriptionSSRC == 0 { + return + } + remoteUfrag := info.AuthToken + if remoteUfrag == "" { + remoteUfrag = info.Token + } + if remoteUfrag == "" { + return + } + message, err := BuildBindingRequestWithSubscriptions([]byte(remoteUfrag+":"+localUfrag), []byte(info.Key), BuildSenderSubscriptions(subscriptionSSRC), true, true) + if err == nil { + _ = m.sendRaw(connection, message) + } +} + +func (m *PionRelayTransport) startKeepalive(connection *pionRelayConnection) { + ping, err := BuildWhatsAppPing() + if err == nil { + _ = m.sendRaw(connection, ping) + } + ticker := time.NewTicker(relayKeepaliveInterval) + connection.mu.Lock() + connection.keepalive = ticker + connection.mu.Unlock() + go func() { + for { + select { + case <-ticker.C: + if !connection.isOpen() { + return + } + if ping, pingErr := BuildWhatsAppPing(); pingErr == nil { + _ = m.sendRaw(connection, ping) + } + case <-connection.stopCh: + return + } + } + }() +} + +func (m *PionRelayTransport) sendRaw(connection *pionRelayConnection, data []byte) error { + connection.mu.RLock() + channel := connection.channel + open := connection.state == relayStateOpen && channel != nil + connection.mu.RUnlock() + if !open { + return fmt.Errorf("relay %s is not open", connection.id) + } + if err := channel.Send(data); err != nil { + return fmt.Errorf("send relay data: %w", err) + } + return nil +} + +func (m *PionRelayTransport) Broadcast(data []byte) error { + var sendErrors []error + for _, connection := range m.connectionSnapshot() { + if !connection.isOpen() { + continue + } + if err := m.sendRaw(connection, data); err != nil { + sendErrors = append(sendErrors, err) + } + } + return errors.Join(sendErrors...) +} + +func (m *PionRelayTransport) HasConnection() bool { + return m.ConnectedCount() > 0 +} + +func (m *PionRelayTransport) ConnectedCount() int { + count := 0 + for _, connection := range m.connectionSnapshot() { + if connection.isOpen() { + count++ + } + } + return count +} + +func (m *PionRelayTransport) connectionSnapshot() []*pionRelayConnection { + m.mu.RLock() + defer m.mu.RUnlock() + connections := make([]*pionRelayConnection, 0, len(m.connections)) + for _, connection := range m.connections { + connections = append(connections, connection) + } + return connections +} + +func (m *PionRelayTransport) failConnection(connection *pionRelayConnection) { + if !connection.setTerminal(relayStateFailed) { + return + } + m.removeConnection(connection) + m.teardown(connection) +} + +func (m *PionRelayTransport) closeConnection(identity string) { + m.mu.RLock() + connection := m.connections[identity] + m.mu.RUnlock() + if connection == nil || !connection.setTerminal(relayStateClosed) { + return + } + m.removeConnection(connection) + m.teardown(connection) +} + +func (m *PionRelayTransport) removeConnection(connection *pionRelayConnection) { + m.mu.Lock() + if current := m.connections[connection.id]; current == connection { + delete(m.connections, connection.id) + } + m.mu.Unlock() +} + +func (m *PionRelayTransport) teardown(connection *pionRelayConnection) { + connection.stopOnce.Do(func() { close(connection.stopCh) }) + connection.mu.Lock() + ticker := connection.keepalive + channel := connection.channel + peerConnection := connection.pc + connection.keepalive = nil + connection.channel = nil + connection.pc = nil + zeroRelayConfig(&connection.info) + connection.mu.Unlock() + if ticker != nil { + ticker.Stop() + } + if channel != nil { + _ = channel.Close() + } + if peerConnection != nil { + _ = peerConnection.Close() + } +} + +func (m *PionRelayTransport) Cleanup() { + m.mu.Lock() + connections := make([]*pionRelayConnection, 0, len(m.connections)) + for _, connection := range m.connections { + connections = append(connections, connection) + } + m.connections = make(map[string]*pionRelayConnection) + m.ssrc = 0 + m.subscriptionSSRC = 0 + m.mu.Unlock() + for _, connection := range connections { + connection.setTerminal(relayStateClosed) + m.teardown(connection) + } +} + +func cloneRelayConfig(config RelayConfig) RelayConfig { + clone := config + clone.RawToken = append([]byte(nil), config.RawToken...) + clone.RawAuthToken = append([]byte(nil), config.RawAuthToken...) + return clone +} + +func zeroRelayConfig(config *RelayConfig) { + if config == nil { + return + } + zeroBytes(config.RawToken) + zeroBytes(config.RawAuthToken) + config.RawToken = nil + config.RawAuthToken = nil + config.Token = "" + config.AuthToken = "" + config.Key = "" + config.Name = "" + config.AuthTokenID = "" +} + +var _ RelayTransport = (*PionRelayTransport)(nil) From 3cace843749c4b89b4067d12fcfc6b84967457b6 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:56:17 -0300 Subject: [PATCH 073/266] test(call): cover experimental Pion transport --- pkg/call/voip/transport/pion_relay_test.go | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 pkg/call/voip/transport/pion_relay_test.go diff --git a/pkg/call/voip/transport/pion_relay_test.go b/pkg/call/voip/transport/pion_relay_test.go new file mode 100644 index 00000000..dc2a91f4 --- /dev/null +++ b/pkg/call/voip/transport/pion_relay_test.go @@ -0,0 +1,85 @@ +//go:build voip_pion + +package transport + +import ( + "log/slog" + "strings" + "testing" +) + +func TestPionFactorySelectsExperimentalTransport(t *testing.T) { + transport := NewRelayTransport(slog.Default()) + if _, ok := transport.(*PionRelayTransport); !ok { + t.Fatalf("expected Pion relay transport, got %T", transport) + } +} + +func TestModifySDPForRelay(t *testing.T) { + input := strings.Join([]string{ + "v=0", + "a=setup:actpass", + "a=ice-ufrag:local-user", + "a=ice-pwd:local-password", + "a=fingerprint:sha-256 LOCAL", + "a=max-message-size:65536", + "a=ice-options:trickle", + "a=candidate:1 1 udp 1 10.0.0.1 9999 typ host", + "a=end-of-candidates", + "", + }, "\r\n") + + output := modifySDPForRelay(input, RelayConfig{ + IP: "203.0.113.9", + Port: 3480, + Token: "relay-token", + AuthToken: "relay-auth", + Key: "relay-password", + }) + + for _, expected := range []string{ + "a=setup:passive", + "a=ice-ufrag:relay-auth", + "a=ice-pwd:relay-password", + "a=max-message-size:1500", + "203.0.113.9 3480 typ host", + } { + if !strings.Contains(output, expected) { + t.Fatalf("modified SDP does not contain %q:\n%s", expected, output) + } + } + for _, removed := range []string{"local-user", "local-password", "10.0.0.1 9999", "ice-options:trickle"} { + if strings.Contains(output, removed) { + t.Fatalf("modified SDP still contains %q:\n%s", removed, output) + } + } +} + +func TestRelayConfigCloneAndZero(t *testing.T) { + original := RelayConfig{ + Token: "token", + AuthToken: "auth", + RawToken: []byte{1, 2, 3}, + RawAuthToken: []byte{4, 5, 6}, + Key: "password", + } + clone := cloneRelayConfig(original) + clone.RawToken[0] = 99 + if original.RawToken[0] != 1 { + t.Fatal("clone shares raw token storage") + } + zeroRelayConfig(&clone) + if clone.Token != "" || clone.AuthToken != "" || clone.Key != "" || clone.RawToken != nil || clone.RawAuthToken != nil { + t.Fatalf("relay config was not cleared: %#v", clone) + } +} + +func TestPionTransportStartsDisconnected(t *testing.T) { + transport := NewPionRelayTransport(nil) + transport.SetSSRC(123) + transport.SetSubscriptionSSRC(456) + if transport.HasConnection() || transport.ConnectedCount() != 0 { + t.Fatal("new transport unexpectedly has a relay connection") + } + transport.Cleanup() +} From c901410262d55c4400a2e56a7297b1fa8f09cef2 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:56:44 -0300 Subject: [PATCH 074/266] ci(call): validate experimental Pion relay --- .github/workflows/finalize-pion-relay.yml | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/finalize-pion-relay.yml diff --git a/.github/workflows/finalize-pion-relay.yml b/.github/workflows/finalize-pion-relay.yml new file mode 100644 index 00000000..8d711983 --- /dev/null +++ b/.github/workflows/finalize-pion-relay.yml @@ -0,0 +1,59 @@ +name: Finalize experimental Pion relay + +on: + pull_request: + paths: + - .github/workflows/finalize-pion-relay.yml + - pkg/call/voip/transport/** + - pkg/call/voip/core/relay_constants.go + - go.mod + - go.sum + +permissions: + contents: write + +jobs: + finalize: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: dev/astracalls-integration + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Add Pion and normalize module files + run: | + go get github.com/pion/webrtc/v4@v4.2.15 + go mod tidy + gofmt -w \ + pkg/call/voip/core/relay_constants.go \ + pkg/call/voip/transport/factory_default.go \ + pkg/call/voip/transport/foundation_test.go \ + pkg/call/voip/transport/pion_relay.go \ + pkg/call/voip/transport/pion_relay_test.go \ + pkg/call/voip/transport/stun.go \ + pkg/call/voip/transport/subscriptions.go + + - name: Test default call build + run: go test -race ./pkg/call/... + + - name: Test experimental Pion build + run: go test -race -tags=voip_pion ./pkg/call/... + + - name: Commit normalized implementation + run: | + rm .github/workflows/finalize-pion-relay.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "feat(call): finalize experimental Pion relay transport" + git push origin HEAD:dev/astracalls-integration From bdf02c58e0077cb4de2d643b6937b1b18708d83d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:57:50 +0000 Subject: [PATCH 075/266] feat(call): finalize experimental Pion relay transport --- .github/workflows/finalize-pion-relay.yml | 59 ----------------------- go.mod | 18 +++++++ go.sum | 38 +++++++++++++++ pkg/call/voip/transport/pion_relay.go | 16 +++--- 4 files changed, 64 insertions(+), 67 deletions(-) delete mode 100644 .github/workflows/finalize-pion-relay.yml diff --git a/.github/workflows/finalize-pion-relay.yml b/.github/workflows/finalize-pion-relay.yml deleted file mode 100644 index 8d711983..00000000 --- a/.github/workflows/finalize-pion-relay.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Finalize experimental Pion relay - -on: - pull_request: - paths: - - .github/workflows/finalize-pion-relay.yml - - pkg/call/voip/transport/** - - pkg/call/voip/core/relay_constants.go - - go.mod - - go.sum - -permissions: - contents: write - -jobs: - finalize: - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout integration branch - uses: actions/checkout@v4 - with: - ref: dev/astracalls-integration - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Add Pion and normalize module files - run: | - go get github.com/pion/webrtc/v4@v4.2.15 - go mod tidy - gofmt -w \ - pkg/call/voip/core/relay_constants.go \ - pkg/call/voip/transport/factory_default.go \ - pkg/call/voip/transport/foundation_test.go \ - pkg/call/voip/transport/pion_relay.go \ - pkg/call/voip/transport/pion_relay_test.go \ - pkg/call/voip/transport/stun.go \ - pkg/call/voip/transport/subscriptions.go - - - name: Test default call build - run: go test -race ./pkg/call/... - - - name: Test experimental Pion build - run: go test -race -tags=voip_pion ./pkg/call/... - - - name: Commit normalized implementation - run: | - rm .github/workflows/finalize-pion-relay.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "feat(call): finalize experimental Pion relay transport" - git push origin HEAD:dev/astracalls-integration diff --git a/go.mod b/go.mod index c1c97f6a..c4c4936f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/minio/minio-go/v7 v7.0.80 github.com/nats-io/nats.go v1.39.0 github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pion/webrtc/v4 v4.2.15 github.com/rabbitmq/amqp091-go v1.10.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/swaggo/files v1.0.1 @@ -76,6 +77,21 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v3 v3.1.4 // indirect + github.com/pion/ice/v4 v4.2.7 // indirect + github.com/pion/interceptor v0.1.45 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.10.2 // indirect + github.com/pion/sctp v1.10.0 // indirect + github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/srtp/v3 v3.0.11 // indirect + github.com/pion/stun/v3 v3.1.5 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect + github.com/pion/turn/v5 v5.0.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rs/xid v1.6.0 // indirect @@ -83,6 +99,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect + github.com/wlynxg/anet v0.0.5 // indirect go.mau.fi/libsignal v0.2.2 // indirect go.mau.fi/util v0.9.10 // indirect golang.org/x/arch v0.10.0 // indirect @@ -90,6 +107,7 @@ require ( golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.46.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect diff --git a/go.sum b/go.sum index c8e3eefc..0061db56 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,40 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= +github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= +github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY= +github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo= +github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= +github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg= +github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= +github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= +github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= +github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= +github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM= +github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= +github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0= +github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= @@ -176,6 +210,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= @@ -227,6 +263,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/pkg/call/voip/transport/pion_relay.go b/pkg/call/voip/transport/pion_relay.go index b990c0b6..5e41e40a 100644 --- a/pkg/call/voip/transport/pion_relay.go +++ b/pkg/call/voip/transport/pion_relay.go @@ -265,15 +265,15 @@ func (m *PionRelayTransport) connectToRelay(connection *pionRelayConnection) err } var ( - relaySetupPattern = regexp.MustCompile(`a=setup:actpass`) - relayUfragLinePattern = regexp.MustCompile(`a=ice-ufrag:[^\r\n]+`) - relayPasswordPattern = regexp.MustCompile(`a=ice-pwd:[^\r\n]+`) - relayFingerprintPattern = regexp.MustCompile(`a=fingerprint:[^\r\n]+`) - relayMaxMessagePattern = regexp.MustCompile(`a=max-message-size:[^\r\n]+`) - relayICEOptionsPattern = regexp.MustCompile(`a=ice-options:[^\r\n]+\r?\n`) - relayCandidatePattern = regexp.MustCompile(`a=candidate:[^\r\n]+\r?\n`) + relaySetupPattern = regexp.MustCompile(`a=setup:actpass`) + relayUfragLinePattern = regexp.MustCompile(`a=ice-ufrag:[^\r\n]+`) + relayPasswordPattern = regexp.MustCompile(`a=ice-pwd:[^\r\n]+`) + relayFingerprintPattern = regexp.MustCompile(`a=fingerprint:[^\r\n]+`) + relayMaxMessagePattern = regexp.MustCompile(`a=max-message-size:[^\r\n]+`) + relayICEOptionsPattern = regexp.MustCompile(`a=ice-options:[^\r\n]+\r?\n`) + relayCandidatePattern = regexp.MustCompile(`a=candidate:[^\r\n]+\r?\n`) relayEndCandidatePattern = regexp.MustCompile(`a=end-of-candidates\r?\n?`) - relayUfragPattern = regexp.MustCompile(`a=ice-ufrag:([^\r\n]+)`) + relayUfragPattern = regexp.MustCompile(`a=ice-ufrag:([^\r\n]+)`) ) func modifySDPForRelay(sdp string, info RelayConfig) string { From 7ba152e989007c31c18b49bcab73e4c269ca2e22 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:59:34 -0300 Subject: [PATCH 076/266] feat(call): add deterministic WhatsApp SSRC derivation --- pkg/call/voip/media/ssrc.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 pkg/call/voip/media/ssrc.go diff --git a/pkg/call/voip/media/ssrc.go b/pkg/call/voip/media/ssrc.go new file mode 100644 index 00000000..ee36bdb0 --- /dev/null +++ b/pkg/call/voip/media/ssrc.go @@ -0,0 +1,31 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import ( + "crypto/hkdf" + "crypto/sha256" + "encoding/binary" + "fmt" +) + +// GenerateSecureSSRC deterministically derives a WhatsApp media SSRC from the +// call ID, device JID and stream counter. +func GenerateSecureSSRC(callID, deviceJID string, counter uint32) (uint32, error) { + if callID == "" { + return 0, fmt.Errorf("call ID is empty") + } + if deviceJID == "" { + return 0, fmt.Errorf("device JID is empty") + } + salt := make([]byte, 4) + binary.LittleEndian.PutUint32(salt, counter) + output, err := hkdf.Key(sha256.New, []byte(callID), salt, deviceJID, 4) + if err != nil { + return 0, fmt.Errorf("derive SSRC: %w", err) + } + ssrc := binary.LittleEndian.Uint32(output) + if ssrc == 0 { + return 0, fmt.Errorf("derived SSRC is zero") + } + return ssrc, nil +} From 2883e866a511a749d4c0a2f98f641cce239e840c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:59:46 -0300 Subject: [PATCH 077/266] test(call): cover SSRC derivation --- pkg/call/voip/media/ssrc_test.go | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 pkg/call/voip/media/ssrc_test.go diff --git a/pkg/call/voip/media/ssrc_test.go b/pkg/call/voip/media/ssrc_test.go new file mode 100644 index 00000000..9e5125a8 --- /dev/null +++ b/pkg/call/voip/media/ssrc_test.go @@ -0,0 +1,41 @@ +package media + +import "testing" + +func TestGenerateSecureSSRCIsDeterministic(t *testing.T) { + first, err := GenerateSecureSSRC("call-123", "5511999999999:1@s.whatsapp.net", 0) + if err != nil { + t.Fatal(err) + } + second, err := GenerateSecureSSRC("call-123", "5511999999999:1@s.whatsapp.net", 0) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatalf("SSRC is not deterministic: %d != %d", first, second) + } +} + +func TestGenerateSecureSSRCChangesWithInputs(t *testing.T) { + base, err := GenerateSecureSSRC("call-123", "5511999999999:1@s.whatsapp.net", 0) + if err != nil { + t.Fatal(err) + } + counter, _ := GenerateSecureSSRC("call-123", "5511999999999:1@s.whatsapp.net", 1) + peer, _ := GenerateSecureSSRC("call-123", "5511888888888:1@s.whatsapp.net", 0) + otherCall, _ := GenerateSecureSSRC("call-456", "5511999999999:1@s.whatsapp.net", 0) + for name, value := range map[string]uint32{"counter": counter, "peer": peer, "call": otherCall} { + if value == base { + t.Fatalf("%s input did not change SSRC", name) + } + } +} + +func TestGenerateSecureSSRCValidatesInputs(t *testing.T) { + if _, err := GenerateSecureSSRC("", "device", 0); err == nil { + t.Fatal("expected empty call ID error") + } + if _, err := GenerateSecureSSRC("call", "", 0); err == nil { + t.Fatal("expected empty device JID error") + } +} From 16cc416d109d1343050e09a95625d18978affca7 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:00:18 -0300 Subject: [PATCH 078/266] feat(call): expose private relay negotiation bridge --- pkg/call/voip/incoming/relay_bridge.go | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 pkg/call/voip/incoming/relay_bridge.go diff --git a/pkg/call/voip/incoming/relay_bridge.go b/pkg/call/voip/incoming/relay_bridge.go new file mode 100644 index 00000000..57921028 --- /dev/null +++ b/pkg/call/voip/incoming/relay_bridge.go @@ -0,0 +1,64 @@ +package incoming + +import ( + "fmt" + + call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + waBinary "go.mau.fi/whatsmeow/binary" +) + +// CaptureRelayNode merges relay metadata into private call material. Nothing is +// copied into the public runtime snapshot. +func (r *Registry) CaptureRelayNode(instanceID, callID string, node *waBinary.Node) { + r.mu.RLock() + session := r.sessions[instanceID] + r.mu.RUnlock() + if session != nil { + session.captureRelays(callID, node) + } +} + +// EnsureRemoteAccepted idempotently advances an outgoing call to connecting. +func (r *Registry) EnsureRemoteAccepted(instanceID, callID string) error { + r.mu.RLock() + session := r.sessions[instanceID] + r.mu.RUnlock() + if session == nil { + return fmt.Errorf("call runtime is not attached for instance %s", instanceID) + } + state, ok := session.state(callID) + if !ok { + return fmt.Errorf("call %s has no private state", callID) + } + switch state.StateData.State { + case core.CallStateConnecting, core.CallStateActive, core.CallStateOnHold: + return nil + case core.CallStateRinging: + return session.transition(callID, call_state.Transition{Type: call_state.TransitionRemoteAccepted}) + default: + return fmt.Errorf("call %s cannot accept a remote answer in state %s", callID, state.StateData.State) + } +} + +// MarkMediaConnected idempotently advances a negotiated call to active. +func (r *Registry) MarkMediaConnected(instanceID, callID string) error { + r.mu.RLock() + session := r.sessions[instanceID] + r.mu.RUnlock() + if session == nil { + return fmt.Errorf("call runtime is not attached for instance %s", instanceID) + } + state, ok := session.state(callID) + if !ok { + return fmt.Errorf("call %s has no private state", callID) + } + switch state.StateData.State { + case core.CallStateActive, core.CallStateOnHold: + return nil + case core.CallStateConnecting: + return session.transition(callID, call_state.Transition{Type: call_state.TransitionMediaConnected}) + default: + return fmt.Errorf("call %s cannot connect media in state %s", callID, state.StateData.State) + } +} From 44ef6a706c4705cd69ac493f27bf20f58855c64a Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:00:51 -0300 Subject: [PATCH 079/266] feat(call): add per-call relay orchestration --- pkg/call/voip/media/relay_registry.go | 336 ++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 pkg/call/voip/media/relay_registry.go diff --git a/pkg/call/voip/media/relay_registry.go b/pkg/call/voip/media/relay_registry.go new file mode 100644 index 00000000..74776ad2 --- /dev/null +++ b/pkg/call/voip/media/relay_registry.go @@ -0,0 +1,336 @@ +package media + +import ( + "errors" + "fmt" + "log/slog" + "sync" + + call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + call_transport "github.com/evolution-foundation/evolution-go/pkg/call/voip/transport" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" + "go.mau.fi/whatsmeow" + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +// NegotiationSource exposes private call state without exposing keys or relay +// tokens to the public runtime or HTTP layer. +type NegotiationSource interface { + RelayData(instanceID, callID string) (*core.RelayData, bool) + State(instanceID, callID string) (*call_state.Info, bool) + CaptureRelayNode(instanceID, callID string, node *waBinary.Node) + EnsureRemoteAccepted(instanceID, callID string) error + MarkMediaConnected(instanceID, callID string) error +} + +type RelayFactory func(log *slog.Logger) call_transport.RelayTransport + +type relaySession struct { + mu sync.Mutex + instanceID string + client *whatsmeow.Client + handlerID uint32 + source NegotiationSource + factory RelayFactory + log *slog.Logger + transports map[string]call_transport.RelayTransport + configuring map[string]bool + ownJID func() types.JID + onConnected func(instanceID, callID string) + onPacket func(instanceID, callID string, packet []byte) +} + +func newRelaySession(instanceID string, client *whatsmeow.Client, source NegotiationSource, factory RelayFactory, log *slog.Logger) *relaySession { + if log == nil { + log = slog.Default() + } + if factory == nil { + factory = call_transport.NewRelayTransport + } + session := &relaySession{ + instanceID: instanceID, + client: client, + source: source, + factory: factory, + log: log, + transports: make(map[string]call_transport.RelayTransport), + configuring: make(map[string]bool), + } + session.ownJID = func() types.JID { + if client == nil { + return types.JID{} + } + socket := wa.NewSocket(client) + jid := socket.OwnLID() + if jid.IsEmpty() { + jid = socket.OwnPN() + } + return jid + } + if client != nil { + session.handlerID = client.AddEventHandler(session.handleEvent) + } + return session +} + +func (s *relaySession) usesClient(client *whatsmeow.Client) bool { + s.mu.Lock() + defer s.mu.Unlock() + return client != nil && s.client == client +} + +func (s *relaySession) handleEvent(rawEvent interface{}) { + switch event := rawEvent.(type) { + case *events.CallAccept: + _ = s.source.EnsureRemoteAccepted(s.instanceID, event.CallID) + s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) + go s.start(event.CallID) + case *events.CallTransport: + s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) + go s.start(event.CallID) + case *events.CallReject: + s.remove(event.CallID) + case *events.CallTerminate: + s.remove(event.CallID) + case *events.Disconnected: + s.cleanup() + case *events.LoggedOut: + s.cleanup() + } +} + +func (s *relaySession) start(callID string) error { + if callID == "" || s.source == nil { + return nil + } + state, ok := s.source.State(s.instanceID, callID) + if !ok || state == nil || state.StateData.State != core.CallStateConnecting { + return nil + } + relayData, ok := s.source.RelayData(s.instanceID, callID) + if !ok || relayData == nil { + return nil + } + defer core.ZeroRelayData(relayData) + configs := call_transport.BuildRelayConfigs(relayData.Endpoints) + if len(configs) == 0 { + return nil + } + defer call_transport.ZeroRelayConfigs(configs) + + s.mu.Lock() + if s.configuring[callID] { + s.mu.Unlock() + return nil + } + s.configuring[callID] = true + relay := s.transports[callID] + if relay == nil { + relay = s.factory(s.log) + s.transports[callID] = relay + relay.SetOnConnected(func(_, _ int) {}) + } + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.configuring, callID) + s.mu.Unlock() + }() + + ownJID := types.JID{} + if s.ownJID != nil { + ownJID = s.ownJID() + } + peerJID, err := types.ParseJID(state.PeerJID) + if err != nil || peerJID.IsEmpty() || ownJID.IsEmpty() { + return fmt.Errorf("resolve SSRC participants for call %s", callID) + } + selfDevice, peerDevice := selectDeviceJIDs(relayData.ParticipantJIDs, ownJID, peerJID) + selfSSRC, err := GenerateSecureSSRC(callID, selfDevice, 0) + if err != nil { + return err + } + peerSSRC, err := GenerateSecureSSRC(callID, peerDevice, 0) + if err != nil { + return err + } + + relay.SetSSRC(selfSSRC) + relay.SetSubscriptionSSRC(peerSSRC) + relay.SetOnConnected(func(_ string, _ int) { + if err := s.source.MarkMediaConnected(s.instanceID, callID); err != nil { + s.log.Debug("ignore stale relay connection callback", "instance", s.instanceID, "call_id", callID, "err", err) + return + } + if s.onConnected != nil { + s.onConnected(s.instanceID, callID) + } + }) + relay.SetOnReceive(func(packet []byte) { + if s.onPacket != nil { + s.onPacket(s.instanceID, callID, append([]byte(nil), packet...)) + } + }) + + if err := relay.ConfigureRelays(configs); err != nil { + if errors.Is(err, call_transport.ErrSCTPUnavailable) { + s.remove(callID) + return nil + } + return fmt.Errorf("configure relays for call %s: %w", callID, err) + } + return nil +} + +func selectDeviceJIDs(participants []string, ownJID, peerJID types.JID) (string, string) { + selfDevice := ownJID.String() + peerDevice := peerJID.String() + for _, participant := range participants { + jid, err := types.ParseJID(participant) + if err != nil || jid.IsEmpty() { + continue + } + if jid.User == ownJID.User { + selfDevice = jid.String() + } + if jid.User == peerJID.User { + peerDevice = jid.String() + } + } + return selfDevice, peerDevice +} + +func (s *relaySession) remove(callID string) { + s.mu.Lock() + relay := s.transports[callID] + delete(s.transports, callID) + delete(s.configuring, callID) + s.mu.Unlock() + if relay != nil { + relay.Cleanup() + } +} + +func (s *relaySession) cleanup() { + s.mu.Lock() + transports := make([]call_transport.RelayTransport, 0, len(s.transports)) + for callID, relay := range s.transports { + transports = append(transports, relay) + delete(s.transports, callID) + } + s.configuring = make(map[string]bool) + s.mu.Unlock() + for _, relay := range transports { + relay.Cleanup() + } +} + +func (s *relaySession) close() { + s.mu.Lock() + client := s.client + handlerID := s.handlerID + s.client = nil + s.handlerID = 0 + s.mu.Unlock() + if client != nil && handlerID != 0 { + client.RemoveEventHandler(handlerID) + } + s.cleanup() +} + +// RelayRegistry owns one relay-event session per Evolution instance. +type RelayRegistry struct { + mu sync.RWMutex + source NegotiationSource + factory RelayFactory + log *slog.Logger + sessions map[string]*relaySession + onConnected func(instanceID, callID string) + onPacket func(instanceID, callID string, packet []byte) +} + +func NewRelayRegistry(source NegotiationSource, factory RelayFactory, log *slog.Logger) *RelayRegistry { + if log == nil { + log = slog.Default() + } + return &RelayRegistry{ + source: source, + factory: factory, + log: log, + sessions: make(map[string]*relaySession), + } +} + +func (r *RelayRegistry) SetOnConnected(callback func(instanceID, callID string)) { + r.mu.Lock() + r.onConnected = callback + for _, session := range r.sessions { + session.onConnected = callback + } + r.mu.Unlock() +} + +func (r *RelayRegistry) SetOnPacket(callback func(instanceID, callID string, packet []byte)) { + r.mu.Lock() + r.onPacket = callback + for _, session := range r.sessions { + session.onPacket = callback + } + r.mu.Unlock() +} + +func (r *RelayRegistry) Attach(instanceID string, client *whatsmeow.Client) { + if instanceID == "" || client == nil { + return + } + r.mu.RLock() + current := r.sessions[instanceID] + if current != nil && current.usesClient(client) { + r.mu.RUnlock() + return + } + r.mu.RUnlock() + + candidate := newRelaySession(instanceID, client, r.source, r.factory, r.log) + r.mu.Lock() + candidate.onConnected = r.onConnected + candidate.onPacket = r.onPacket + previous := r.sessions[instanceID] + r.sessions[instanceID] = candidate + r.mu.Unlock() + if previous != nil { + previous.close() + } +} + +func (r *RelayRegistry) Start(instanceID, callID string) error { + r.mu.RLock() + session := r.sessions[instanceID] + r.mu.RUnlock() + if session == nil { + return fmt.Errorf("relay runtime is not attached for instance %s", instanceID) + } + return session.start(callID) +} + +func (r *RelayRegistry) Remove(instanceID, callID string) { + r.mu.RLock() + session := r.sessions[instanceID] + r.mu.RUnlock() + if session != nil { + session.remove(callID) + } +} + +func (r *RelayRegistry) Close(instanceID string) { + r.mu.Lock() + session := r.sessions[instanceID] + delete(r.sessions, instanceID) + r.mu.Unlock() + if session != nil { + session.close() + } +} From d857d47e8cb81ab5ec7a25aa2eb9b7b817651294 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:01:44 -0300 Subject: [PATCH 080/266] test(call): cover relay orchestration lifecycle --- pkg/call/voip/media/relay_registry_test.go | 202 +++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 pkg/call/voip/media/relay_registry_test.go diff --git a/pkg/call/voip/media/relay_registry_test.go b/pkg/call/voip/media/relay_registry_test.go new file mode 100644 index 00000000..738fb42f --- /dev/null +++ b/pkg/call/voip/media/relay_registry_test.go @@ -0,0 +1,202 @@ +package media + +import ( + "errors" + "log/slog" + "testing" + + call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + call_transport "github.com/evolution-foundation/evolution-go/pkg/call/voip/transport" + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +type fakeNegotiationSource struct { + state *call_state.Info + relayData *core.RelayData + connected int + captureHits int +} + +func (f *fakeNegotiationSource) RelayData(_, _ string) (*core.RelayData, bool) { + if f.relayData == nil { + return nil, false + } + return core.CloneRelayData(f.relayData), true +} +func (f *fakeNegotiationSource) State(_, _ string) (*call_state.Info, bool) { + if f.state == nil { + return nil, false + } + return f.state.Clone(), true +} +func (f *fakeNegotiationSource) CaptureRelayNode(_, _ string, _ *waBinary.Node) { + f.captureHits++ +} +func (f *fakeNegotiationSource) EnsureRemoteAccepted(_, _ string) error { + if f.state.StateData.State == core.CallStateRinging { + return f.state.Apply(call_state.Transition{Type: call_state.TransitionRemoteAccepted}) + } + return nil +} +func (f *fakeNegotiationSource) MarkMediaConnected(_, _ string) error { + if err := f.state.Apply(call_state.Transition{Type: call_state.TransitionMediaConnected}); err != nil { + return err + } + f.connected++ + return nil +} + +type fakeRelayTransport struct { + ssrc uint32 + subscriptionSSRC uint32 + configs []call_transport.RelayConfig + onConnected func(string, int) + onReceive func([]byte) + configureErr error + cleaned bool +} + +func (f *fakeRelayTransport) SetSSRC(ssrc uint32) { f.ssrc = ssrc } +func (f *fakeRelayTransport) SetSubscriptionSSRC(ssrc uint32) { f.subscriptionSSRC = ssrc } +func (f *fakeRelayTransport) SetOnConnected(callback func(string, int)) { f.onConnected = callback } +func (f *fakeRelayTransport) SetOnReceive(callback func([]byte)) { f.onReceive = callback } +func (f *fakeRelayTransport) ResendSubscriptions() {} +func (f *fakeRelayTransport) ConfigureRelays(configs []call_transport.RelayConfig) error { + f.configs = append([]call_transport.RelayConfig(nil), configs...) + return f.configureErr +} +func (f *fakeRelayTransport) Broadcast([]byte) error { return nil } +func (f *fakeRelayTransport) HasConnection() bool { return false } +func (f *fakeRelayTransport) ConnectedCount() int { return 0 } +func (f *fakeRelayTransport) Cleanup() { f.cleaned = true } + +func TestRelaySessionConfiguresTransportAndMarksMediaConnected(t *testing.T) { + state := call_state.NewOutgoing( + "call-1", + "5511888888888:2@s.whatsapp.net", + "5511999999999:1@s.whatsapp.net", + core.CallMediaTypeAudio, + ) + if err := state.Apply(call_state.Transition{Type: call_state.TransitionOfferSent}); err != nil { + t.Fatal(err) + } + if err := state.Apply(call_state.Transition{Type: call_state.TransitionRemoteAccepted}); err != nil { + t.Fatal(err) + } + + source := &fakeNegotiationSource{ + state: state, + relayData: &core.RelayData{ + Endpoints: []core.RelayEndpoint{{ + IP: "203.0.113.10", + Port: 3480, + Protocol: 0, + Key: "relay-password", + RawToken: []byte{1, 2, 3}, + }}, + ParticipantJIDs: []string{ + "5511999999999:7@s.whatsapp.net", + "5511888888888:9@s.whatsapp.net", + }, + }, + } + fakeTransport := &fakeRelayTransport{} + session := &relaySession{ + instanceID: "instance-1", + source: source, + factory: func(*slog.Logger) call_transport.RelayTransport { return fakeTransport }, + log: slog.Default(), + transports: make(map[string]call_transport.RelayTransport), + configuring: make(map[string]bool), + ownJID: func() types.JID { + return types.NewJID("5511999999999", types.DefaultUserServer) + }, + } + connectedCallback := 0 + session.onConnected = func(_, _ string) { connectedCallback++ } + + if err := session.start("call-1"); err != nil { + t.Fatal(err) + } + if fakeTransport.ssrc == 0 || fakeTransport.subscriptionSSRC == 0 { + t.Fatalf("SSRCs were not configured: self=%d peer=%d", fakeTransport.ssrc, fakeTransport.subscriptionSSRC) + } + if len(fakeTransport.configs) != 1 || fakeTransport.configs[0].IP != "203.0.113.10" { + t.Fatalf("unexpected relay configs: %#v", fakeTransport.configs) + } + if fakeTransport.onConnected == nil { + t.Fatal("connected callback was not installed") + } + fakeTransport.onConnected("203.0.113.10", 3480) + if source.state.StateData.State != core.CallStateActive || source.connected != 1 || connectedCallback != 1 { + t.Fatalf("media connection was not propagated: state=%s source=%d callback=%d", source.state.StateData.State, source.connected, connectedCallback) + } +} + +func TestRelaySessionTreatsDisabledTransportAsNoop(t *testing.T) { + state := call_state.NewIncoming("call-2", "5511888888888@s.whatsapp.net", "5511888888888@s.whatsapp.net", core.CallMediaTypeAudio) + if err := state.Apply(call_state.Transition{Type: call_state.TransitionLocalAccepted}); err != nil { + t.Fatal(err) + } + source := &fakeNegotiationSource{ + state: state, + relayData: &core.RelayData{Endpoints: []core.RelayEndpoint{{ + IP: "203.0.113.11", Protocol: 0, Key: "key", RawToken: []byte{4, 5, 6}, + }}}, + } + fakeTransport := &fakeRelayTransport{configureErr: call_transport.ErrSCTPUnavailable} + session := &relaySession{ + instanceID: "instance-1", + source: source, + factory: func(*slog.Logger) call_transport.RelayTransport { return fakeTransport }, + log: slog.Default(), + transports: make(map[string]call_transport.RelayTransport), + configuring: make(map[string]bool), + ownJID: func() types.JID { + return types.NewJID("5511999999999", types.DefaultUserServer) + }, + } + if err := session.start("call-2"); err != nil { + t.Fatal(err) + } + if !fakeTransport.cleaned { + t.Fatal("disabled transport was not cleaned up") + } + if _, exists := session.transports["call-2"]; exists { + t.Fatal("disabled transport remained registered") + } +} + +func TestRelaySessionReturnsRealConfigurationErrors(t *testing.T) { + state := call_state.NewIncoming("call-3", "5511888888888@s.whatsapp.net", "5511888888888@s.whatsapp.net", core.CallMediaTypeAudio) + _ = state.Apply(call_state.Transition{Type: call_state.TransitionLocalAccepted}) + source := &fakeNegotiationSource{ + state: state, + relayData: &core.RelayData{Endpoints: []core.RelayEndpoint{{ + IP: "203.0.113.12", Protocol: 0, Key: "key", RawToken: []byte{7}, + }}}, + } + expected := errors.New("setup failed") + session := &relaySession{ + instanceID: "instance-1", source: source, + factory: func(*slog.Logger) call_transport.RelayTransport { return &fakeRelayTransport{configureErr: expected} }, + log: slog.Default(), transports: make(map[string]call_transport.RelayTransport), configuring: make(map[string]bool), + ownJID: func() types.JID { return types.NewJID("5511999999999", types.DefaultUserServer) }, + } + if err := session.start("call-3"); !errors.Is(err, expected) { + t.Fatalf("expected setup error, got %v", err) + } +} + +func TestSelectDeviceJIDsPrefersParticipantDevices(t *testing.T) { + self, peer := selectDeviceJIDs( + []string{"5511999999999:7@s.whatsapp.net", "5511888888888:9@s.whatsapp.net"}, + types.NewJID("5511999999999", types.DefaultUserServer), + types.NewJID("5511888888888", types.HiddenUserServer), + ) + if self != "5511999999999:7@s.whatsapp.net" || peer != "5511888888888:9@s.whatsapp.net" { + t.Fatalf("unexpected participant selection: self=%s peer=%s", self, peer) + } +} From 71968f42a0213cab4707a9e68ed84f68f2960861 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:02:20 -0300 Subject: [PATCH 081/266] feat(call): connect relay lifecycle to coordinator --- pkg/call/lifecycle/coordinator.go | 40 ++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go index 1e102fc6..535ac1c6 100644 --- a/pkg/call/lifecycle/coordinator.go +++ b/pkg/call/lifecycle/coordinator.go @@ -1,5 +1,5 @@ -// Package lifecycle coordinates call state and private negotiation material -// for each Evolution WhatsApp client. +// Package lifecycle coordinates call state, private negotiation material and +// experimental media relays for each Evolution WhatsApp client. package lifecycle import ( @@ -9,6 +9,7 @@ import ( call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" call_incoming "github.com/evolution-foundation/evolution-go/pkg/call/voip/incoming" + call_media "github.com/evolution-foundation/evolution-go/pkg/call/voip/media" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" ) @@ -19,15 +20,24 @@ type Coordinator struct { mu sync.RWMutex runtimes *call_runtime.Registry incoming *call_incoming.Registry + relays *call_media.RelayRegistry incomingEnabled map[string]bool } func NewCoordinator() *Coordinator { - return &Coordinator{ + incoming := call_incoming.NewRegistry() + coordinator := &Coordinator{ runtimes: call_runtime.NewRegistry(), - incoming: call_incoming.NewRegistry(), + incoming: incoming, incomingEnabled: make(map[string]bool), } + coordinator.relays = call_media.NewRelayRegistry(incoming, nil, nil) + coordinator.relays.SetOnConnected(func(instanceID, callID string) { + if runtime, ok := coordinator.runtimes.Get(instanceID); ok { + runtime.Transition(callID, "", "", call_runtime.StateActive, nil, "") + } + }) + return coordinator } // AttachClient is called by the WhatsApp client lifecycle. Public call state is @@ -44,9 +54,11 @@ func (c *Coordinator) AttachClient(instanceID string, client *whatsmeow.Client, c.runtimes.Attach(instanceID, client) c.incoming.Attach(instanceID, client, prepareIncoming) + c.relays.Attach(instanceID, client) } -// DetachClient removes handlers, configuration and private call keys. +// DetachClient removes handlers, relay connections, configuration and private +// call keys before the WhatsApp client is discarded. func (c *Coordinator) DetachClient(instanceID string) { if c == nil || instanceID == "" { return @@ -54,6 +66,7 @@ func (c *Coordinator) DetachClient(instanceID string) { c.mu.Lock() delete(c.incomingEnabled, instanceID) c.mu.Unlock() + c.relays.Close(instanceID) c.runtimes.Remove(instanceID) c.incoming.Close(instanceID) } @@ -73,6 +86,7 @@ func (c *Coordinator) Attach(instanceID string, client *whatsmeow.Client) { prepareIncoming = true } c.incoming.Attach(instanceID, client, prepareIncoming) + c.relays.Attach(instanceID, client) } func (c *Coordinator) Detach(instanceID string) { @@ -102,8 +116,8 @@ func (c *Coordinator) StoreOutgoing(instanceID, callID string, callKey []byte, p return c.incoming.StoreOutgoing(instanceID, callID, callKey, peer, creator, video, relayData) } -// RelayData returns a defensive copy for the future SCTP transport manager. -// The caller owns the copy and must call core.ZeroRelayData after use. +// RelayData returns a defensive copy. The caller owns the copy and must call +// core.ZeroRelayData after use. func (c *Coordinator) RelayData(instanceID, callID string) (*core.RelayData, bool) { if c == nil { return nil, false @@ -112,14 +126,22 @@ func (c *Coordinator) RelayData(instanceID, callID string) (*core.RelayData, boo } func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID string) error { - return c.incoming.Accept(ctx, instanceID, callID) + if err := c.incoming.Accept(ctx, instanceID, callID); err != nil { + return err + } + return c.relays.Start(instanceID, callID) } func (c *Coordinator) TerminateIncoming(ctx context.Context, instanceID, callID string) error { - return c.incoming.Terminate(ctx, instanceID, callID) + if err := c.incoming.Terminate(ctx, instanceID, callID); err != nil { + return err + } + c.relays.Remove(instanceID, callID) + return nil } func (c *Coordinator) RemovePrivate(instanceID, callID string) { + c.relays.Remove(instanceID, callID) c.incoming.Remove(instanceID, callID) } From 38239eca6f6788b7e3b7be3d0ca94e3d3ee2ef84 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:03:06 -0300 Subject: [PATCH 082/266] ci(call): prepare relay orchestration migration --- tools/apply_media_orchestration.py | 127 +++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tools/apply_media_orchestration.py diff --git a/tools/apply_media_orchestration.py b/tools/apply_media_orchestration.py new file mode 100644 index 00000000..edde3ba7 --- /dev/null +++ b/tools/apply_media_orchestration.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def replace_exact(path: Path, old: str, new: str, expected: int = 1) -> None: + text = path.read_text() + count = text.count(old) + if count != expected: + raise RuntimeError(f"{path}: expected {expected} matches, found {count}: {old[:100]!r}") + path.write_text(text.replace(old, new, expected)) + + +relay = Path("pkg/call/voip/media/relay_registry.go") +replace_exact(relay, "\t\trelay.SetOnConnected(func(_, _ int) {})\n", "") +replace_exact( + relay, + "\t\tgo s.start(event.CallID)\n", + "\t\tgo s.startLogged(event.CallID)\n", + expected=2, +) +replace_exact( + relay, + "func (s *relaySession) start(callID string) error {\n", + "func (s *relaySession) startLogged(callID string) {\n" + "\tif err := s.start(callID); err != nil {\n" + "\t\ts.log.Warn(\"WhatsApp relay setup failed\", \"instance\", s.instanceID, \"call_id\", callID, \"err\", err)\n" + "\t}\n" + "}\n\n" + "func (s *relaySession) start(callID string) error {\n", +) +replace_exact( + relay, + "\t\tif s.onConnected != nil {\n" + "\t\t\ts.onConnected(s.instanceID, callID)\n" + "\t\t}\n", + "\t\ts.mu.Lock()\n" + "\t\tcallback := s.onConnected\n" + "\t\ts.mu.Unlock()\n" + "\t\tif callback != nil {\n" + "\t\t\tcallback(s.instanceID, callID)\n" + "\t\t}\n", +) +replace_exact( + relay, + "\trelay.SetOnReceive(func(packet []byte) {\n" + "\t\tif s.onPacket != nil {\n" + "\t\t\ts.onPacket(s.instanceID, callID, append([]byte(nil), packet...))\n" + "\t\t}\n" + "\t})\n", + "\trelay.SetOnReceive(func(packet []byte) {\n" + "\t\ts.mu.Lock()\n" + "\t\tcallback := s.onPacket\n" + "\t\ts.mu.Unlock()\n" + "\t\tif callback != nil {\n" + "\t\t\tcallback(s.instanceID, callID, append([]byte(nil), packet...))\n" + "\t\t}\n" + "\t})\n", +) +replace_exact( + relay, + "func (r *RelayRegistry) Start(instanceID, callID string) error {\n" + "\tr.mu.RLock()\n" + "\tsession := r.sessions[instanceID]\n" + "\tr.mu.RUnlock()\n" + "\tif session == nil {\n" + "\t\treturn fmt.Errorf(\"relay runtime is not attached for instance %s\", instanceID)\n" + "\t}\n" + "\treturn session.start(callID)\n" + "}\n", + "func (r *RelayRegistry) Start(instanceID, callID string) error {\n" + "\tr.mu.RLock()\n" + "\tsession := r.sessions[instanceID]\n" + "\tr.mu.RUnlock()\n" + "\tif session == nil {\n" + "\t\treturn fmt.Errorf(\"relay runtime is not attached for instance %s\", instanceID)\n" + "\t}\n" + "\terr := session.start(callID)\n" + "\tif err != nil {\n" + "\t\tr.log.Warn(\"WhatsApp relay setup failed\", \"instance\", instanceID, \"call_id\", callID, \"err\", err)\n" + "\t}\n" + "\treturn err\n" + "}\n", +) + +coordinator = Path("pkg/call/lifecycle/coordinator.go") +replace_exact( + coordinator, + "func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID string) error {\n" + "\tif err := c.incoming.Accept(ctx, instanceID, callID); err != nil {\n" + "\t\treturn err\n" + "\t}\n" + "\treturn c.relays.Start(instanceID, callID)\n" + "}\n", + "func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID string) error {\n" + "\tif err := c.incoming.Accept(ctx, instanceID, callID); err != nil {\n" + "\t\treturn err\n" + "\t}\n" + "\tgo func() { _ = c.relays.Start(instanceID, callID) }()\n" + "\treturn nil\n" + "}\n", +) + +runtime = Path("pkg/call/runtime/runtime.go") +replace_exact( + runtime, + "\tcase *events.CallAccept:\n" + "\t\tr.Transition(\n" + "\t\t\tevent.CallID,\n" + "\t\t\tcallPeer(event.CallCreator, event.From),\n" + "\t\t\tDirectionOutgoing,\n" + "\t\t\tStateActive,\n" + "\t\t\tnil,\n" + "\t\t\t\"\",\n" + "\t\t)\n", + "\tcase *events.CallAccept:\n" + "\t\tr.Transition(\n" + "\t\t\tevent.CallID,\n" + "\t\t\tcallPeer(event.CallCreator, event.From),\n" + "\t\t\tDirectionOutgoing,\n" + "\t\t\tStateConnecting,\n" + "\t\t\tnil,\n" + "\t\t\t\"\",\n" + "\t\t)\n", +) + +Path("tools/apply_media_orchestration.py").unlink() +Path(".github/workflows/apply-media-orchestration.yml").unlink() From f6cdb166fb67b4a988e90056e492478db0004417 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:03:22 -0300 Subject: [PATCH 083/266] ci(call): validate relay orchestration --- .../workflows/apply-media-orchestration.yml | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/apply-media-orchestration.yml diff --git a/.github/workflows/apply-media-orchestration.yml b/.github/workflows/apply-media-orchestration.yml new file mode 100644 index 00000000..ee968869 --- /dev/null +++ b/.github/workflows/apply-media-orchestration.yml @@ -0,0 +1,57 @@ +name: Apply relay orchestration + +on: + pull_request: + paths: + - .github/workflows/apply-media-orchestration.yml + - tools/apply_media_orchestration.py + - pkg/call/** + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: dev/astracalls-integration + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Apply orchestration migration + run: python3 tools/apply_media_orchestration.py + + - name: Format call implementation + run: | + gofmt -w \ + pkg/call/lifecycle/coordinator.go \ + pkg/call/runtime/runtime.go \ + pkg/call/voip/incoming/relay_bridge.go \ + pkg/call/voip/media/relay_registry.go \ + pkg/call/voip/media/relay_registry_test.go \ + pkg/call/voip/media/ssrc.go \ + pkg/call/voip/media/ssrc_test.go + + - name: Test default build + run: go test -race ./pkg/call/... + + - name: Test experimental Pion build + run: go test -race -tags=voip_pion ./pkg/call/... + + - name: Commit orchestration + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "feat(call): connect relay transport to call lifecycle" + git push origin HEAD:dev/astracalls-integration From 211ec3036383696aabe9b5ae93f79aad21e47a0c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:04:41 -0300 Subject: [PATCH 084/266] test(call): align runtime state with media lifecycle --- tools/fix_runtime_media_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tools/fix_runtime_media_test.py diff --git a/tools/fix_runtime_media_test.py b/tools/fix_runtime_media_test.py new file mode 100644 index 00000000..1a9069f6 --- /dev/null +++ b/tools/fix_runtime_media_test.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("pkg/call/runtime/runtime_test.go") +text = path.read_text() +old = '''\tcall, _ = runtime.Call("call-1") +\tif call.State != StateActive { +\t\tt.Fatalf("expected active state, got %s", call.State) +\t} +''' +new = '''\tcall, _ = runtime.Call("call-1") +\tif call.State != StateConnecting { +\t\tt.Fatalf("expected connecting state before media, got %s", call.State) +\t} +''' +if text.count(old) != 1: + raise RuntimeError("runtime lifecycle expectation did not match exactly once") +path.write_text(text.replace(old, new, 1)) +Path("tools/fix_runtime_media_test.py").unlink() From 50e06d1b86e9f594d6a4fcddcc3c9494690f64e9 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:04:55 -0300 Subject: [PATCH 085/266] ci(call): rerun relay orchestration tests --- .github/workflows/apply-media-orchestration.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/apply-media-orchestration.yml b/.github/workflows/apply-media-orchestration.yml index ee968869..7426b3df 100644 --- a/.github/workflows/apply-media-orchestration.yml +++ b/.github/workflows/apply-media-orchestration.yml @@ -5,6 +5,7 @@ on: paths: - .github/workflows/apply-media-orchestration.yml - tools/apply_media_orchestration.py + - tools/fix_runtime_media_test.py - pkg/call/** permissions: @@ -29,13 +30,16 @@ jobs: cache: true - name: Apply orchestration migration - run: python3 tools/apply_media_orchestration.py + run: | + python3 tools/apply_media_orchestration.py + python3 tools/fix_runtime_media_test.py - name: Format call implementation run: | gofmt -w \ pkg/call/lifecycle/coordinator.go \ pkg/call/runtime/runtime.go \ + pkg/call/runtime/runtime_test.go \ pkg/call/voip/incoming/relay_bridge.go \ pkg/call/voip/media/relay_registry.go \ pkg/call/voip/media/relay_registry_test.go \ From aa39f414d7a3fc5065d92c663795e8e5e3f85543 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:05:41 +0000 Subject: [PATCH 086/266] feat(call): connect relay transport to call lifecycle --- .../workflows/apply-media-orchestration.yml | 61 --------- pkg/call/lifecycle/coordinator.go | 3 +- pkg/call/runtime/runtime.go | 2 +- pkg/call/runtime/runtime_test.go | 10 +- pkg/call/voip/media/relay_registry.go | 31 +++-- pkg/call/voip/media/relay_registry_test.go | 12 +- tools/apply_media_orchestration.py | 127 ------------------ tools/fix_runtime_media_test.py | 19 --- 8 files changed, 37 insertions(+), 228 deletions(-) delete mode 100644 .github/workflows/apply-media-orchestration.yml delete mode 100644 tools/apply_media_orchestration.py delete mode 100644 tools/fix_runtime_media_test.py diff --git a/.github/workflows/apply-media-orchestration.yml b/.github/workflows/apply-media-orchestration.yml deleted file mode 100644 index 7426b3df..00000000 --- a/.github/workflows/apply-media-orchestration.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Apply relay orchestration - -on: - pull_request: - paths: - - .github/workflows/apply-media-orchestration.yml - - tools/apply_media_orchestration.py - - tools/fix_runtime_media_test.py - - pkg/call/** - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout integration branch - uses: actions/checkout@v4 - with: - ref: dev/astracalls-integration - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Apply orchestration migration - run: | - python3 tools/apply_media_orchestration.py - python3 tools/fix_runtime_media_test.py - - - name: Format call implementation - run: | - gofmt -w \ - pkg/call/lifecycle/coordinator.go \ - pkg/call/runtime/runtime.go \ - pkg/call/runtime/runtime_test.go \ - pkg/call/voip/incoming/relay_bridge.go \ - pkg/call/voip/media/relay_registry.go \ - pkg/call/voip/media/relay_registry_test.go \ - pkg/call/voip/media/ssrc.go \ - pkg/call/voip/media/ssrc_test.go - - - name: Test default build - run: go test -race ./pkg/call/... - - - name: Test experimental Pion build - run: go test -race -tags=voip_pion ./pkg/call/... - - - name: Commit orchestration - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "feat(call): connect relay transport to call lifecycle" - git push origin HEAD:dev/astracalls-integration diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go index 535ac1c6..28820778 100644 --- a/pkg/call/lifecycle/coordinator.go +++ b/pkg/call/lifecycle/coordinator.go @@ -129,7 +129,8 @@ func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID str if err := c.incoming.Accept(ctx, instanceID, callID); err != nil { return err } - return c.relays.Start(instanceID, callID) + go func() { _ = c.relays.Start(instanceID, callID) }() + return nil } func (c *Coordinator) TerminateIncoming(ctx context.Context, instanceID, callID string) error { diff --git a/pkg/call/runtime/runtime.go b/pkg/call/runtime/runtime.go index 397e19df..95356772 100644 --- a/pkg/call/runtime/runtime.go +++ b/pkg/call/runtime/runtime.go @@ -260,7 +260,7 @@ func (r *Runtime) handleEvent(rawEvent interface{}) { event.CallID, callPeer(event.CallCreator, event.From), DirectionOutgoing, - StateActive, + StateConnecting, nil, "", ) diff --git a/pkg/call/runtime/runtime_test.go b/pkg/call/runtime/runtime_test.go index 15c8dbf6..28d5bb49 100644 --- a/pkg/call/runtime/runtime_test.go +++ b/pkg/call/runtime/runtime_test.go @@ -96,7 +96,7 @@ func TestRuntimeTracksWhatsmeowCallLifecycle(t *testing.T) { BasicCallMeta: types.BasicCallMeta{ From: creator, CallCreator: creator, - CallID: "call-1", + CallID: "call-1", }, Data: &waBinary.Node{ Tag: "offer", @@ -121,13 +121,13 @@ func TestRuntimeTracksWhatsmeowCallLifecycle(t *testing.T) { BasicCallMeta: types.BasicCallMeta{ From: creator, CallCreator: creator, - CallID: "call-1", + CallID: "call-1", }, }) call, _ = runtime.Call("call-1") - if call.State != StateActive { - t.Fatalf("expected active state, got %s", call.State) + if call.State != StateConnecting { + t.Fatalf("expected connecting state before media, got %s", call.State) } if call.Direction != DirectionIncoming { t.Fatalf("incoming direction must be preserved, got %s", call.Direction) @@ -137,7 +137,7 @@ func TestRuntimeTracksWhatsmeowCallLifecycle(t *testing.T) { BasicCallMeta: types.BasicCallMeta{ From: creator, CallCreator: creator, - CallID: "call-1", + CallID: "call-1", }, Reason: "peer_hangup", }) diff --git a/pkg/call/voip/media/relay_registry.go b/pkg/call/voip/media/relay_registry.go index 74776ad2..959a9269 100644 --- a/pkg/call/voip/media/relay_registry.go +++ b/pkg/call/voip/media/relay_registry.go @@ -87,10 +87,10 @@ func (s *relaySession) handleEvent(rawEvent interface{}) { case *events.CallAccept: _ = s.source.EnsureRemoteAccepted(s.instanceID, event.CallID) s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) - go s.start(event.CallID) + go s.startLogged(event.CallID) case *events.CallTransport: s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) - go s.start(event.CallID) + go s.startLogged(event.CallID) case *events.CallReject: s.remove(event.CallID) case *events.CallTerminate: @@ -102,6 +102,12 @@ func (s *relaySession) handleEvent(rawEvent interface{}) { } } +func (s *relaySession) startLogged(callID string) { + if err := s.start(callID); err != nil { + s.log.Warn("WhatsApp relay setup failed", "instance", s.instanceID, "call_id", callID, "err", err) + } +} + func (s *relaySession) start(callID string) error { if callID == "" || s.source == nil { return nil @@ -131,7 +137,6 @@ func (s *relaySession) start(callID string) error { if relay == nil { relay = s.factory(s.log) s.transports[callID] = relay - relay.SetOnConnected(func(_, _ int) {}) } s.mu.Unlock() defer func() { @@ -165,13 +170,19 @@ func (s *relaySession) start(callID string) error { s.log.Debug("ignore stale relay connection callback", "instance", s.instanceID, "call_id", callID, "err", err) return } - if s.onConnected != nil { - s.onConnected(s.instanceID, callID) + s.mu.Lock() + callback := s.onConnected + s.mu.Unlock() + if callback != nil { + callback(s.instanceID, callID) } }) relay.SetOnReceive(func(packet []byte) { - if s.onPacket != nil { - s.onPacket(s.instanceID, callID, append([]byte(nil), packet...)) + s.mu.Lock() + callback := s.onPacket + s.mu.Unlock() + if callback != nil { + callback(s.instanceID, callID, append([]byte(nil), packet...)) } }) @@ -313,7 +324,11 @@ func (r *RelayRegistry) Start(instanceID, callID string) error { if session == nil { return fmt.Errorf("relay runtime is not attached for instance %s", instanceID) } - return session.start(callID) + err := session.start(callID) + if err != nil { + r.log.Warn("WhatsApp relay setup failed", "instance", instanceID, "call_id", callID, "err", err) + } + return err } func (r *RelayRegistry) Remove(instanceID, callID string) { diff --git a/pkg/call/voip/media/relay_registry_test.go b/pkg/call/voip/media/relay_registry_test.go index 738fb42f..c8074f28 100644 --- a/pkg/call/voip/media/relay_registry_test.go +++ b/pkg/call/voip/media/relay_registry_test.go @@ -58,11 +58,11 @@ type fakeRelayTransport struct { cleaned bool } -func (f *fakeRelayTransport) SetSSRC(ssrc uint32) { f.ssrc = ssrc } -func (f *fakeRelayTransport) SetSubscriptionSSRC(ssrc uint32) { f.subscriptionSSRC = ssrc } -func (f *fakeRelayTransport) SetOnConnected(callback func(string, int)) { f.onConnected = callback } -func (f *fakeRelayTransport) SetOnReceive(callback func([]byte)) { f.onReceive = callback } -func (f *fakeRelayTransport) ResendSubscriptions() {} +func (f *fakeRelayTransport) SetSSRC(ssrc uint32) { f.ssrc = ssrc } +func (f *fakeRelayTransport) SetSubscriptionSSRC(ssrc uint32) { f.subscriptionSSRC = ssrc } +func (f *fakeRelayTransport) SetOnConnected(callback func(string, int)) { f.onConnected = callback } +func (f *fakeRelayTransport) SetOnReceive(callback func([]byte)) { f.onReceive = callback } +func (f *fakeRelayTransport) ResendSubscriptions() {} func (f *fakeRelayTransport) ConfigureRelays(configs []call_transport.RelayConfig) error { f.configs = append([]call_transport.RelayConfig(nil), configs...) return f.configureErr @@ -182,7 +182,7 @@ func TestRelaySessionReturnsRealConfigurationErrors(t *testing.T) { session := &relaySession{ instanceID: "instance-1", source: source, factory: func(*slog.Logger) call_transport.RelayTransport { return &fakeRelayTransport{configureErr: expected} }, - log: slog.Default(), transports: make(map[string]call_transport.RelayTransport), configuring: make(map[string]bool), + log: slog.Default(), transports: make(map[string]call_transport.RelayTransport), configuring: make(map[string]bool), ownJID: func() types.JID { return types.NewJID("5511999999999", types.DefaultUserServer) }, } if err := session.start("call-3"); !errors.Is(err, expected) { diff --git a/tools/apply_media_orchestration.py b/tools/apply_media_orchestration.py deleted file mode 100644 index edde3ba7..00000000 --- a/tools/apply_media_orchestration.py +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - - -def replace_exact(path: Path, old: str, new: str, expected: int = 1) -> None: - text = path.read_text() - count = text.count(old) - if count != expected: - raise RuntimeError(f"{path}: expected {expected} matches, found {count}: {old[:100]!r}") - path.write_text(text.replace(old, new, expected)) - - -relay = Path("pkg/call/voip/media/relay_registry.go") -replace_exact(relay, "\t\trelay.SetOnConnected(func(_, _ int) {})\n", "") -replace_exact( - relay, - "\t\tgo s.start(event.CallID)\n", - "\t\tgo s.startLogged(event.CallID)\n", - expected=2, -) -replace_exact( - relay, - "func (s *relaySession) start(callID string) error {\n", - "func (s *relaySession) startLogged(callID string) {\n" - "\tif err := s.start(callID); err != nil {\n" - "\t\ts.log.Warn(\"WhatsApp relay setup failed\", \"instance\", s.instanceID, \"call_id\", callID, \"err\", err)\n" - "\t}\n" - "}\n\n" - "func (s *relaySession) start(callID string) error {\n", -) -replace_exact( - relay, - "\t\tif s.onConnected != nil {\n" - "\t\t\ts.onConnected(s.instanceID, callID)\n" - "\t\t}\n", - "\t\ts.mu.Lock()\n" - "\t\tcallback := s.onConnected\n" - "\t\ts.mu.Unlock()\n" - "\t\tif callback != nil {\n" - "\t\t\tcallback(s.instanceID, callID)\n" - "\t\t}\n", -) -replace_exact( - relay, - "\trelay.SetOnReceive(func(packet []byte) {\n" - "\t\tif s.onPacket != nil {\n" - "\t\t\ts.onPacket(s.instanceID, callID, append([]byte(nil), packet...))\n" - "\t\t}\n" - "\t})\n", - "\trelay.SetOnReceive(func(packet []byte) {\n" - "\t\ts.mu.Lock()\n" - "\t\tcallback := s.onPacket\n" - "\t\ts.mu.Unlock()\n" - "\t\tif callback != nil {\n" - "\t\t\tcallback(s.instanceID, callID, append([]byte(nil), packet...))\n" - "\t\t}\n" - "\t})\n", -) -replace_exact( - relay, - "func (r *RelayRegistry) Start(instanceID, callID string) error {\n" - "\tr.mu.RLock()\n" - "\tsession := r.sessions[instanceID]\n" - "\tr.mu.RUnlock()\n" - "\tif session == nil {\n" - "\t\treturn fmt.Errorf(\"relay runtime is not attached for instance %s\", instanceID)\n" - "\t}\n" - "\treturn session.start(callID)\n" - "}\n", - "func (r *RelayRegistry) Start(instanceID, callID string) error {\n" - "\tr.mu.RLock()\n" - "\tsession := r.sessions[instanceID]\n" - "\tr.mu.RUnlock()\n" - "\tif session == nil {\n" - "\t\treturn fmt.Errorf(\"relay runtime is not attached for instance %s\", instanceID)\n" - "\t}\n" - "\terr := session.start(callID)\n" - "\tif err != nil {\n" - "\t\tr.log.Warn(\"WhatsApp relay setup failed\", \"instance\", instanceID, \"call_id\", callID, \"err\", err)\n" - "\t}\n" - "\treturn err\n" - "}\n", -) - -coordinator = Path("pkg/call/lifecycle/coordinator.go") -replace_exact( - coordinator, - "func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID string) error {\n" - "\tif err := c.incoming.Accept(ctx, instanceID, callID); err != nil {\n" - "\t\treturn err\n" - "\t}\n" - "\treturn c.relays.Start(instanceID, callID)\n" - "}\n", - "func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID string) error {\n" - "\tif err := c.incoming.Accept(ctx, instanceID, callID); err != nil {\n" - "\t\treturn err\n" - "\t}\n" - "\tgo func() { _ = c.relays.Start(instanceID, callID) }()\n" - "\treturn nil\n" - "}\n", -) - -runtime = Path("pkg/call/runtime/runtime.go") -replace_exact( - runtime, - "\tcase *events.CallAccept:\n" - "\t\tr.Transition(\n" - "\t\t\tevent.CallID,\n" - "\t\t\tcallPeer(event.CallCreator, event.From),\n" - "\t\t\tDirectionOutgoing,\n" - "\t\t\tStateActive,\n" - "\t\t\tnil,\n" - "\t\t\t\"\",\n" - "\t\t)\n", - "\tcase *events.CallAccept:\n" - "\t\tr.Transition(\n" - "\t\t\tevent.CallID,\n" - "\t\t\tcallPeer(event.CallCreator, event.From),\n" - "\t\t\tDirectionOutgoing,\n" - "\t\t\tStateConnecting,\n" - "\t\t\tnil,\n" - "\t\t\t\"\",\n" - "\t\t)\n", -) - -Path("tools/apply_media_orchestration.py").unlink() -Path(".github/workflows/apply-media-orchestration.yml").unlink() diff --git a/tools/fix_runtime_media_test.py b/tools/fix_runtime_media_test.py deleted file mode 100644 index 1a9069f6..00000000 --- a/tools/fix_runtime_media_test.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -path = Path("pkg/call/runtime/runtime_test.go") -text = path.read_text() -old = '''\tcall, _ = runtime.Call("call-1") -\tif call.State != StateActive { -\t\tt.Fatalf("expected active state, got %s", call.State) -\t} -''' -new = '''\tcall, _ = runtime.Call("call-1") -\tif call.State != StateConnecting { -\t\tt.Fatalf("expected connecting state before media, got %s", call.State) -\t} -''' -if text.count(old) != 1: - raise RuntimeError("runtime lifecycle expectation did not match exactly once") -path.write_text(text.replace(old, new, 1)) -Path("tools/fix_runtime_media_test.py").unlink() From 1d88804017e319803bf855de50f917f907deb6f9 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:06:06 -0300 Subject: [PATCH 087/266] ci(call): test default and Pion relay builds --- .github/workflows/voip-integration.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/voip-integration.yml b/.github/workflows/voip-integration.yml index 139a9fa6..1b9754bc 100644 --- a/.github/workflows/voip-integration.yml +++ b/.github/workflows/voip-integration.yml @@ -18,7 +18,7 @@ permissions: jobs: test-call-module: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Checkout @@ -33,5 +33,8 @@ jobs: - name: Download dependencies run: go mod download - - name: Test call packages + - name: Test default call build run: go test -race ./pkg/call/... + + - name: Test experimental Pion relay build + run: go test -race -tags=voip_pion ./pkg/call/... From 7329c597243a6bdeca9b117632e8a69f2deaa31a Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:06:37 -0300 Subject: [PATCH 088/266] docs(call): document experimental Pion relay lifecycle --- docs/wiki/guias-api/api-calls-experimental.md | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md index 80a29b1b..19c111bf 100644 --- a/docs/wiki/guias-api/api-calls-experimental.md +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -2,7 +2,7 @@ Esta branch adiciona a integração experimental WaCalls/AstraCalls ao Evolution Go. -> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. Chaves e metadados de relay já são associados ao estado privado de cada chamada, mas o transporte de áudio/WebRTC/SRTP ainda não foi conectado. Não use em produção. +> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante experimental também consegue negociar DataChannels com os relays usando Pion. RTP/SRTP e áudio ainda não foram conectados. Não use em produção. Todas as rotas usam a autenticação normal da instância do Evolution. @@ -12,7 +12,7 @@ Todas as rotas usam a autenticação normal da instância do Evolution. GET /call/status ``` -Os monitores de chamada são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, os handlers anteriores são removidos e o material privado é apagado antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento. +Os monitores de chamada são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels e material privado são removidos antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento. Exemplo de resposta: @@ -80,6 +80,8 @@ O runtime descriptografa a chave recebida usando a sessão Signal já autenticad } ``` +`CallAccept` não marca mais a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que um DataChannel de relay abre e o callback `media_connected` é aplicado. + Se a preparação criptográfica do evento ainda não terminou, a API retorna um erro informando que a chamada ainda não está pronta para aceite. ## Encerrar uma chamada @@ -89,7 +91,7 @@ DELETE /call/{callId} apikey: INSTANCE_TOKEN ``` -A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove a chave e os dados privados da chamada. +A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove chave, candidatos, DataChannels e demais dados privados da chamada. ## Rejeitar uma chamada recebida @@ -118,7 +120,7 @@ O snapshot público usa: - `ended` - `failed` -Internamente, a negociação também usa uma máquina de estados estrita com: +Internamente, a negociação usa uma máquina de estados estrita com: - `initiating` - `ringing` @@ -130,7 +132,7 @@ Internamente, a negociação também usa uma máquina de estados estrita com: Transições inválidas, como marcar mídia conectada antes do aceite ou aceitar remotamente uma chamada recebida, são rejeitadas sem alterar o estado. -## Relay e transporte +## Relay e transporte Pion O módulo de sinalização reconhece os dois formatos encontrados nas respostas do WhatsApp: @@ -139,21 +141,38 @@ O módulo de sinalização reconhece os dois formatos encontrados nas respostas Os candidatos são ordenados pelo menor RTT e associados ao material privado pelo `callId`. Atualizações posteriores recebidas em `CallTransport` substituem os candidatos anteriores sem apagar a chave da chamada. -O pacote `pkg/call/voip/transport` define o contrato usado pelo futuro gerenciador SCTP. Ele: +A implementação experimental Pion inclui: + +- PeerConnection e DataChannel `wa-web-call` por relay; +- transformação do SDP para credenciais e fingerprint do relay; +- registro STUN com subscriptions de SSRC; +- requisição de allocation; +- tentativas adicionais de registro; +- keepalive proprietário do WhatsApp; +- broadcast e recebimento de frames do DataChannel; +- timeout, fechamento e limpeza de buffers; +- SSRC determinístico derivado de `callId` e JID do dispositivo. -- converte somente candidatos UDP utilizáveis; -- aplica a porta padrão do relay; -- remove duplicados; -- copia tokens binários para buffers independentes; -- oferece limpeza explícita desses buffers; -- usa um transportador desativado por padrão que falha de forma segura sem abrir sockets. +A build padrão continua usando um transportador sem rede. Para compilar a variante experimental: + +```bash +go build -tags=voip_pion ./cmd/evolution-go +``` + +O workflow do PR testa permanentemente as duas variantes: + +```bash +go test -race ./pkg/call/... +go test -race -tags=voip_pion ./pkg/call/... +``` ## Limitações atuais - sem áudio bidirecional; +- os frames recebidos pelo DataChannel ainda não são processados por uma sessão RTP/SRTP; +- sem derivação e instalação das chaves SRTP; +- sem codecs Opus ou MLow conectados ao runtime; - sem WebRTC para navegador; -- o contrato SCTP existe, mas a implementação Pion ainda não está habilitada; -- sem RTP ou SRTP; -- aceitar a sinalização não estabelece o caminho de mídia; +- a conexão real com um relay WhatsApp ainda precisa ser validada de ponta a ponta com uma conta conectada; - as chaves ficam somente em memória e não sobrevivem a reinícios; - API e formatos podem mudar enquanto o PR estiver em rascunho. From 05d985582b2e554b567cb9ddbedeec30234cb9bd Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:08:11 -0300 Subject: [PATCH 089/266] docs(call): clarify experimental relay activation From 39043e8fc1402b3b68e648f8ccecfa8ebe2049f6 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:12:01 -0300 Subject: [PATCH 090/266] feat(call): add SRTP keying domain types --- pkg/call/voip/core/srtp.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 pkg/call/voip/core/srtp.go diff --git a/pkg/call/voip/core/srtp.go b/pkg/call/voip/core/srtp.go new file mode 100644 index 00000000..e5d2793e --- /dev/null +++ b/pkg/call/voip/core/srtp.go @@ -0,0 +1,37 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package core + +const ( + PayloadTypeWhatsAppOpus uint8 = 120 + + SRTPSendAuthTagLen = 4 + SRTPRecvAuthTagLen = 4 + SRTPAuthTagLen = 4 + + SRTPLabelEncryption byte = 0x00 + SRTPLabelAuth byte = 0x01 + SRTPLabelSalt byte = 0x02 +) + +// SRTPKeyingMaterial contains the RFC 3711 master key and master salt. +// Callers own these buffers and must call Wipe after the material is consumed. +type SRTPKeyingMaterial struct { + MasterKey []byte + MasterSalt []byte +} + +func (m *SRTPKeyingMaterial) Wipe() { + if m == nil { + return + } + zeroBytes(m.MasterKey) + zeroBytes(m.MasterSalt) + m.MasterKey = nil + m.MasterSalt = nil +} + +func zeroBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} From 270a0640cee2678ac108b0d18aa28a0202dfdfa4 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:12:11 -0300 Subject: [PATCH 091/266] feat(call): derive per-device SRTP keys --- pkg/call/voip/media/encryption.go | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 pkg/call/voip/media/encryption.go diff --git a/pkg/call/voip/media/encryption.go b/pkg/call/voip/media/encryption.go new file mode 100644 index 00000000..9bc9a2ce --- /dev/null +++ b/pkg/call/voip/media/encryption.go @@ -0,0 +1,46 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import ( + "crypto/hkdf" + "crypto/sha256" + "fmt" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +const ( + whatsAppCallKeyLength = 32 + srtpHKDFOutputLength = 46 + srtpMasterKeyLength = 16 + srtpMasterSaltLength = 14 +) + +// DerivePerJIDSRTPKey derives the SRTP master key and salt bound to one +// WhatsApp device JID. The caller owns the returned buffers and must wipe them. +func DerivePerJIDSRTPKey(callKey []byte, deviceJID string) (core.SRTPKeyingMaterial, error) { + if len(callKey) != whatsAppCallKeyLength { + return core.SRTPKeyingMaterial{}, fmt.Errorf("invalid WhatsApp call key length: %d", len(callKey)) + } + if deviceJID == "" { + return core.SRTPKeyingMaterial{}, fmt.Errorf("device JID is empty") + } + + output, err := hkdf.Key(sha256.New, callKey, nil, deviceJID, srtpHKDFOutputLength) + if err != nil { + return core.SRTPKeyingMaterial{}, fmt.Errorf("derive SRTP key for device: %w", err) + } + defer zeroBytes(output) + + material := core.SRTPKeyingMaterial{ + MasterKey: append([]byte(nil), output[:srtpMasterKeyLength]...), + MasterSalt: append([]byte(nil), output[srtpMasterKeyLength:srtpMasterKeyLength+srtpMasterSaltLength]...), + } + return material, nil +} + +func zeroBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} From 393d631694e5dc5806931365d5a13d4f8604c88c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:12:49 -0300 Subject: [PATCH 092/266] feat(call): add validated RTP framing --- pkg/call/voip/media/rtp.go | 301 +++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 pkg/call/voip/media/rtp.go diff --git a/pkg/call/voip/media/rtp.go b/pkg/call/voip/media/rtp.go new file mode 100644 index 00000000..9ea0f867 --- /dev/null +++ b/pkg/call/voip/media/rtp.go @@ -0,0 +1,301 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + "sync" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +const ( + rtpVersion uint8 = 2 + rtpMinHeaderSize = 12 + maxCSRCCount = 15 +) + +type RTPHeader struct { + Version uint8 + Padding bool + Extension bool + Marker bool + PayloadType uint8 + SequenceNumber uint16 + Timestamp uint32 + SSRC uint32 + CSRC []uint32 + ExtensionProfile uint16 + ExtensionData []byte +} + +func NewRTPHeader(payloadType uint8, sequence uint16, timestamp, ssrc uint32) *RTPHeader { + return &RTPHeader{ + Version: rtpVersion, + PayloadType: payloadType, + SequenceNumber: sequence, + Timestamp: timestamp, + SSRC: ssrc, + } +} + +func (h *RTPHeader) encodedSize() (int, error) { + if h == nil { + return 0, fmt.Errorf("RTP header is nil") + } + if h.Version == 0 { + h.Version = rtpVersion + } + if h.Version != rtpVersion { + return 0, fmt.Errorf("invalid RTP version: %d", h.Version) + } + if len(h.CSRC) > maxCSRCCount { + return 0, fmt.Errorf("too many RTP CSRC entries: %d", len(h.CSRC)) + } + if h.PayloadType > 127 { + return 0, fmt.Errorf("invalid RTP payload type: %d", h.PayloadType) + } + if h.Extension { + if len(h.ExtensionData)%4 != 0 { + return 0, fmt.Errorf("RTP extension length must be a multiple of four: %d", len(h.ExtensionData)) + } + if len(h.ExtensionData)/4 > int(^uint16(0)) { + return 0, fmt.Errorf("RTP extension is too large: %d", len(h.ExtensionData)) + } + } + + size := rtpMinHeaderSize + len(h.CSRC)*4 + if h.Extension { + size += 4 + len(h.ExtensionData) + } + return size, nil +} + +func (h *RTPHeader) MarshalTo(buffer []byte) (int, error) { + size, err := h.encodedSize() + if err != nil { + return 0, err + } + if len(buffer) < size { + return 0, fmt.Errorf("buffer too small for RTP header: got %d, need %d", len(buffer), size) + } + + buffer[0] = (h.Version&0x03)<<6 | boolBit(h.Padding)<<5 | boolBit(h.Extension)<<4 | byte(len(h.CSRC)&0x0f) + buffer[1] = boolBit(h.Marker)<<7 | (h.PayloadType & 0x7f) + binary.BigEndian.PutUint16(buffer[2:4], h.SequenceNumber) + binary.BigEndian.PutUint32(buffer[4:8], h.Timestamp) + binary.BigEndian.PutUint32(buffer[8:12], h.SSRC) + + offset := rtpMinHeaderSize + for _, source := range h.CSRC { + binary.BigEndian.PutUint32(buffer[offset:offset+4], source) + offset += 4 + } + if h.Extension { + binary.BigEndian.PutUint16(buffer[offset:offset+2], h.ExtensionProfile) + binary.BigEndian.PutUint16(buffer[offset+2:offset+4], uint16(len(h.ExtensionData)/4)) + copy(buffer[offset+4:offset+4+len(h.ExtensionData)], h.ExtensionData) + } + return size, nil +} + +func ParseRTPHeader(buffer []byte) (*RTPHeader, int, error) { + if len(buffer) < rtpMinHeaderSize { + return nil, 0, fmt.Errorf("buffer too small for RTP header: %d", len(buffer)) + } + version := (buffer[0] >> 6) & 0x03 + if version != rtpVersion { + return nil, 0, fmt.Errorf("invalid RTP version: %d", version) + } + + csrcCount := int(buffer[0] & 0x0f) + offset := rtpMinHeaderSize + csrcCount*4 + if len(buffer) < offset { + return nil, 0, fmt.Errorf("truncated RTP CSRC list") + } + + header := &RTPHeader{ + Version: version, + Padding: buffer[0]&0x20 != 0, + Extension: buffer[0]&0x10 != 0, + Marker: buffer[1]&0x80 != 0, + PayloadType: buffer[1] & 0x7f, + SequenceNumber: binary.BigEndian.Uint16(buffer[2:4]), + Timestamp: binary.BigEndian.Uint32(buffer[4:8]), + SSRC: binary.BigEndian.Uint32(buffer[8:12]), + CSRC: make([]uint32, 0, csrcCount), + } + + cursor := rtpMinHeaderSize + for index := 0; index < csrcCount; index++ { + header.CSRC = append(header.CSRC, binary.BigEndian.Uint32(buffer[cursor:cursor+4])) + cursor += 4 + } + if header.Extension { + if len(buffer) < cursor+4 { + return nil, 0, fmt.Errorf("truncated RTP extension header") + } + header.ExtensionProfile = binary.BigEndian.Uint16(buffer[cursor : cursor+2]) + extensionLength := int(binary.BigEndian.Uint16(buffer[cursor+2:cursor+4])) * 4 + cursor += 4 + if len(buffer) < cursor+extensionLength { + return nil, 0, fmt.Errorf("truncated RTP extension data") + } + header.ExtensionData = append([]byte(nil), buffer[cursor:cursor+extensionLength]...) + cursor += extensionLength + } + return header, cursor, nil +} + +type RTPPacket struct { + Header *RTPHeader + Payload []byte + PaddingSize uint8 +} + +func (p *RTPPacket) Marshal() ([]byte, error) { + if p == nil || p.Header == nil { + return nil, fmt.Errorf("RTP packet or header is nil") + } + headerSize, err := p.Header.encodedSize() + if err != nil { + return nil, err + } + paddingSize := int(p.PaddingSize) + if p.Header.Padding && paddingSize == 0 { + return nil, fmt.Errorf("RTP padding flag is set without padding bytes") + } + if !p.Header.Padding && paddingSize != 0 { + return nil, fmt.Errorf("RTP padding bytes require the padding flag") + } + + output := make([]byte, headerSize+len(p.Payload)+paddingSize) + if _, err = p.Header.MarshalTo(output); err != nil { + return nil, err + } + copy(output[headerSize:], p.Payload) + if paddingSize > 0 { + output[len(output)-1] = byte(paddingSize) + } + return output, nil +} + +func ParseRTPPacket(buffer []byte) (*RTPPacket, error) { + header, headerSize, err := ParseRTPHeader(buffer) + if err != nil { + return nil, err + } + if len(buffer) < headerSize { + return nil, fmt.Errorf("invalid RTP header size") + } + payloadEnd := len(buffer) + paddingSize := 0 + if header.Padding { + if payloadEnd == headerSize { + return nil, fmt.Errorf("RTP padding flag set on empty payload") + } + paddingSize = int(buffer[payloadEnd-1]) + if paddingSize == 0 || paddingSize > payloadEnd-headerSize { + return nil, fmt.Errorf("invalid RTP padding size: %d", paddingSize) + } + payloadEnd -= paddingSize + } + return &RTPPacket{ + Header: header, + Payload: append([]byte(nil), buffer[headerSize:payloadEnd]...), + PaddingSize: uint8(paddingSize), + }, nil +} + +func (p *RTPPacket) Wipe() { + if p == nil { + return + } + zeroBytes(p.Payload) + p.Payload = nil + if p.Header != nil { + zeroBytes(p.Header.ExtensionData) + p.Header.ExtensionData = nil + p.Header.CSRC = nil + } + p.Header = nil + p.PaddingSize = 0 +} + +type RTPSession struct { + mu sync.Mutex + ssrc uint32 + payloadType uint8 + sequenceNumber uint16 + timestamp uint32 + samplesPerPacket uint32 +} + +func NewRTPSession(ssrc uint32, payloadType uint8, samplesPerPacket uint32) (*RTPSession, error) { + if ssrc == 0 { + return nil, fmt.Errorf("RTP SSRC must be non-zero") + } + if payloadType > 127 { + return nil, fmt.Errorf("invalid RTP payload type: %d", payloadType) + } + if samplesPerPacket == 0 { + return nil, fmt.Errorf("samples per RTP packet must be non-zero") + } + sequence, err := randomUint16() + if err != nil { + return nil, err + } + timestamp, err := randomUint32() + if err != nil { + return nil, err + } + return &RTPSession{ + ssrc: ssrc, + payloadType: payloadType, + sequenceNumber: sequence, + timestamp: timestamp, + samplesPerPacket: samplesPerPacket, + }, nil +} + +func NewWhatsAppOpusRTPSession(ssrc uint32) (*RTPSession, error) { + return NewRTPSession(ssrc, core.PayloadTypeWhatsAppOpus, 960) +} + +func (s *RTPSession) CreatePacket(payload []byte, marker bool) *RTPPacket { + return s.CreatePacketWithDuration(payload, s.samplesPerPacket, marker) +} + +func (s *RTPSession) CreatePacketWithDuration(payload []byte, durationSamples uint32, marker bool) *RTPPacket { + s.mu.Lock() + header := NewRTPHeader(s.payloadType, s.sequenceNumber, s.timestamp, s.ssrc) + header.Marker = marker + s.sequenceNumber++ + s.timestamp += durationSamples + s.mu.Unlock() + return &RTPPacket{Header: header, Payload: append([]byte(nil), payload...)} +} + +func boolBit(value bool) byte { + if value { + return 1 + } + return 0 +} + +func randomUint16() (uint16, error) { + var buffer [2]byte + if _, err := rand.Read(buffer[:]); err != nil { + return 0, fmt.Errorf("generate RTP sequence: %w", err) + } + return binary.BigEndian.Uint16(buffer[:]), nil +} + +func randomUint32() (uint32, error) { + var buffer [4]byte + if _, err := rand.Read(buffer[:]); err != nil { + return 0, fmt.Errorf("generate RTP timestamp: %w", err) + } + return binary.BigEndian.Uint32(buffer[:]), nil +} From a1dd0dd55a2d5985931d1470cc4e5657e3fac656 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:13:57 -0300 Subject: [PATCH 093/266] feat(call): add authenticated SRTP session --- pkg/call/voip/media/srtp.go | 392 ++++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 pkg/call/voip/media/srtp.go diff --git a/pkg/call/voip/media/srtp.go b/pkg/call/voip/media/srtp.go new file mode 100644 index 00000000..767b9913 --- /dev/null +++ b/pkg/call/voip/media/srtp.go @@ -0,0 +1,392 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha1" + "crypto/subtle" + "encoding/binary" + "fmt" + "sync" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +type SRTPErrorType string + +const ( + SRTPErrPacketTooShort SRTPErrorType = "packet_too_short" + SRTPErrAuthFailed SRTPErrorType = "auth_failed" + SRTPErrReplay SRTPErrorType = "replay" + SRTPErrEncryption SRTPErrorType = "encryption" + SRTPErrDecryption SRTPErrorType = "decryption" + SRTPErrInvalidKeying SRTPErrorType = "invalid_keying" + SRTPErrClosed SRTPErrorType = "closed" +) + +type SRTPError struct { + Type SRTPErrorType + Msg string +} + +func (e *SRTPError) Error() string { + if e == nil { + return "srtp error" + } + return fmt.Sprintf("srtp %s: %s", e.Type, e.Msg) +} + +type SRTPContext struct { + mu sync.Mutex + + sessionKey []byte + sessionSalt []byte + authKey []byte + authTagLen int + + initialized bool + highestIndex uint64 + replayWindow uint64 + closed bool +} + +func NewSRTPContext(keying core.SRTPKeyingMaterial, authTagLen int) (*SRTPContext, error) { + if len(keying.MasterKey) != srtpMasterKeyLength { + return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("master key length is %d", len(keying.MasterKey))} + } + if len(keying.MasterSalt) != srtpMasterSaltLength { + return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("master salt length is %d", len(keying.MasterSalt))} + } + if authTagLen <= 0 { + authTagLen = core.SRTPAuthTagLen + } + if authTagLen > sha1.Size { + return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("auth tag length is %d", authTagLen)} + } + + sessionKey, err := deriveSRTPKey(keying.MasterKey, keying.MasterSalt, core.SRTPLabelEncryption, srtpMasterKeyLength) + if err != nil { + return nil, err + } + authKey, err := deriveSRTPKey(keying.MasterKey, keying.MasterSalt, core.SRTPLabelAuth, sha1.Size) + if err != nil { + zeroBytes(sessionKey) + return nil, err + } + sessionSalt, err := deriveSRTPKey(keying.MasterKey, keying.MasterSalt, core.SRTPLabelSalt, srtpMasterSaltLength) + if err != nil { + zeroBytes(sessionKey) + zeroBytes(authKey) + return nil, err + } + return &SRTPContext{ + sessionKey: sessionKey, + sessionSalt: sessionSalt, + authKey: authKey, + authTagLen: authTagLen, + }, nil +} + +func (c *SRTPContext) Protect(packet *RTPPacket) ([]byte, error) { + if packet == nil || packet.Header == nil { + return nil, &SRTPError{Type: SRTPErrEncryption, Msg: "RTP packet or header is nil"} + } + + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil, &SRTPError{Type: SRTPErrClosed, Msg: "context is closed"} + } + + index := estimatePacketIndex(packet.Header.SequenceNumber, c.highestIndex, c.initialized) + if c.initialized && index <= c.highestIndex { + return nil, &SRTPError{Type: SRTPErrEncryption, Msg: "non-monotonic RTP sequence would reuse an SRTP index"} + } + + plain, err := packet.Marshal() + if err != nil { + return nil, &SRTPError{Type: SRTPErrEncryption, Msg: err.Error()} + } + defer zeroBytes(plain) + + _, headerSize, err := ParseRTPHeader(plain) + if err != nil { + return nil, &SRTPError{Type: SRTPErrEncryption, Msg: err.Error()} + } + output := make([]byte, len(plain)+c.authTagLen) + copy(output[:headerSize], plain[:headerSize]) + + iv := c.generateIV(packet.Header.SSRC, index) + if err = aesCTRXOR(c.sessionKey, iv, plain[headerSize:], output[headerSize:len(plain)]); err != nil { + zeroBytes(output) + return nil, &SRTPError{Type: SRTPErrEncryption, Msg: err.Error()} + } + zeroBytes(iv) + + roc := uint32(index >> 16) + tag := c.computeAuthTag(output[:len(plain)], roc) + copy(output[len(plain):], tag) + zeroBytes(tag) + + c.highestIndex = index + c.initialized = true + return output, nil +} + +func (c *SRTPContext) Unprotect(data []byte) (*RTPPacket, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil, &SRTPError{Type: SRTPErrClosed, Msg: "context is closed"} + } + if len(data) < rtpMinHeaderSize+c.authTagLen { + return nil, &SRTPError{Type: SRTPErrPacketTooShort, Msg: fmt.Sprintf("packet is %d bytes", len(data))} + } + + header, headerSize, err := ParseRTPHeader(data) + if err != nil { + return nil, &SRTPError{Type: SRTPErrDecryption, Msg: err.Error()} + } + ciphertextEnd := len(data) - c.authTagLen + if ciphertextEnd <= headerSize { + return nil, &SRTPError{Type: SRTPErrPacketTooShort, Msg: "packet has no encrypted RTP payload"} + } + + index := estimatePacketIndex(header.SequenceNumber, c.highestIndex, c.initialized) + roc := uint32(index >> 16) + expectedTag := c.computeAuthTag(data[:ciphertextEnd], roc) + receivedTag := data[ciphertextEnd:] + if len(expectedTag) != len(receivedTag) || subtle.ConstantTimeCompare(expectedTag, receivedTag) != 1 { + zeroBytes(expectedTag) + return nil, &SRTPError{Type: SRTPErrAuthFailed, Msg: "authentication tag mismatch"} + } + zeroBytes(expectedTag) + + if c.isReplay(index) { + return nil, &SRTPError{Type: SRTPErrReplay, Msg: "packet index was already received or is outside the replay window"} + } + + plain := make([]byte, ciphertextEnd) + copy(plain[:headerSize], data[:headerSize]) + iv := c.generateIV(header.SSRC, index) + if err = aesCTRXOR(c.sessionKey, iv, data[headerSize:ciphertextEnd], plain[headerSize:]); err != nil { + zeroBytes(iv) + zeroBytes(plain) + return nil, &SRTPError{Type: SRTPErrDecryption, Msg: err.Error()} + } + zeroBytes(iv) + + packet, err := ParseRTPPacket(plain) + zeroBytes(plain) + if err != nil { + return nil, &SRTPError{Type: SRTPErrDecryption, Msg: err.Error()} + } + c.commitReceivedIndex(index) + return packet, nil +} + +func (c *SRTPContext) SetAuthenticationKeying(keying core.SRTPKeyingMaterial) error { + if len(keying.MasterKey) != srtpMasterKeyLength || len(keying.MasterSalt) != srtpMasterSaltLength { + return &SRTPError{Type: SRTPErrInvalidKeying, Msg: "invalid authentication keying material"} + } + authKey, err := deriveSRTPKey(keying.MasterKey, keying.MasterSalt, core.SRTPLabelAuth, sha1.Size) + if err != nil { + return err + } + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + zeroBytes(authKey) + return &SRTPError{Type: SRTPErrClosed, Msg: "context is closed"} + } + zeroBytes(c.authKey) + c.authKey = authKey + return nil +} + +func (c *SRTPContext) Close() { + if c == nil { + return + } + c.mu.Lock() + if !c.closed { + zeroBytes(c.sessionKey) + zeroBytes(c.sessionSalt) + zeroBytes(c.authKey) + c.sessionKey = nil + c.sessionSalt = nil + c.authKey = nil + c.highestIndex = 0 + c.replayWindow = 0 + c.initialized = false + c.closed = true + } + c.mu.Unlock() +} + +func (c *SRTPContext) generateIV(ssrc uint32, index uint64) []byte { + iv := make([]byte, aes.BlockSize) + copy(iv, c.sessionSalt) + var ssrcBuffer [4]byte + binary.BigEndian.PutUint32(ssrcBuffer[:], ssrc) + for offset := 0; offset < len(ssrcBuffer); offset++ { + iv[4+offset] ^= ssrcBuffer[offset] + } + var indexBuffer [8]byte + binary.BigEndian.PutUint64(indexBuffer[:], index) + for offset := 0; offset < 6; offset++ { + iv[8+offset] ^= indexBuffer[2+offset] + } + return iv +} + +func (c *SRTPContext) computeAuthTag(data []byte, roc uint32) []byte { + mac := hmac.New(sha1.New, c.authKey) + _, _ = mac.Write(data) + var rocBuffer [4]byte + binary.BigEndian.PutUint32(rocBuffer[:], roc) + _, _ = mac.Write(rocBuffer[:]) + return append([]byte(nil), mac.Sum(nil)[:c.authTagLen]...) +} + +func (c *SRTPContext) isReplay(index uint64) bool { + if !c.initialized || index > c.highestIndex { + return false + } + delta := c.highestIndex - index + if delta >= 64 { + return true + } + return c.replayWindow&(uint64(1)< c.highestIndex { + shift := index - c.highestIndex + if shift >= 64 { + c.replayWindow = 1 + } else { + c.replayWindow = (c.replayWindow << shift) | 1 + } + c.highestIndex = index + return + } + delta := c.highestIndex - index + c.replayWindow |= uint64(1) << delta +} + +func estimatePacketIndex(sequence uint16, highest uint64, initialized bool) uint64 { + if !initialized { + return uint64(sequence) + } + roc := uint32(highest >> 16) + lastSequence := uint16(highest) + guessedROC := roc + if lastSequence < 0x8000 { + if int(sequence)-int(lastSequence) > 0x8000 && roc > 0 { + guessedROC = roc - 1 + } + } else if int(lastSequence)-int(sequence) > 0x8000 { + guessedROC = roc + 1 + } + return (uint64(guessedROC) << 16) | uint64(sequence) +} + +type SRTPSession struct { + send *SRTPContext + recv *SRTPContext +} + +func NewSRTPSession(sendKey, receiveKey core.SRTPKeyingMaterial, sendAuthLen, receiveAuthLen int) (*SRTPSession, error) { + sendContext, err := NewSRTPContext(sendKey, sendAuthLen) + if err != nil { + return nil, err + } + receiveContext, err := NewSRTPContext(receiveKey, receiveAuthLen) + if err != nil { + sendContext.Close() + return nil, err + } + return &SRTPSession{send: sendContext, recv: receiveContext}, nil +} + +func (s *SRTPSession) Protect(packet *RTPPacket) ([]byte, error) { + if s == nil || s.send == nil { + return nil, &SRTPError{Type: SRTPErrClosed, Msg: "send context is unavailable"} + } + return s.send.Protect(packet) +} + +func (s *SRTPSession) Unprotect(data []byte) (*RTPPacket, error) { + if s == nil || s.recv == nil { + return nil, &SRTPError{Type: SRTPErrClosed, Msg: "receive context is unavailable"} + } + return s.recv.Unprotect(data) +} + +func (s *SRTPSession) SetSendAuthenticationKeying(keying core.SRTPKeyingMaterial) error { + if s == nil || s.send == nil { + return &SRTPError{Type: SRTPErrClosed, Msg: "send context is unavailable"} + } + return s.send.SetAuthenticationKeying(keying) +} + +func (s *SRTPSession) Close() { + if s == nil { + return + } + if s.send != nil { + s.send.Close() + } + if s.recv != nil { + s.recv.Close() + } + s.send = nil + s.recv = nil +} + +func deriveSRTPKey(masterKey, masterSalt []byte, label byte, length int) ([]byte, error) { + if len(masterKey) != srtpMasterKeyLength { + return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("master key length is %d", len(masterKey))} + } + if len(masterSalt) != srtpMasterSaltLength { + return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("master salt length is %d", len(masterSalt))} + } + if length <= 0 { + return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("derived key length is %d", length)} + } + iv := make([]byte, aes.BlockSize) + copy(iv, masterSalt) + iv[7] ^= label + output := make([]byte, length) + if err := aesCTRXOR(masterKey, iv, make([]byte, length), output); err != nil { + zeroBytes(iv) + zeroBytes(output) + return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: err.Error()} + } + zeroBytes(iv) + return output, nil +} + +func aesCTRXOR(key, iv, source, destination []byte) error { + if len(iv) != aes.BlockSize { + return fmt.Errorf("invalid AES CTR IV length: %d", len(iv)) + } + if len(source) != len(destination) { + return fmt.Errorf("AES CTR source and destination lengths differ") + } + block, err := aes.NewCipher(key) + if err != nil { + return err + } + cipher.NewCTR(block, iv).XORKeyStream(destination, source) + return nil +} From 7865cd0bc51941e3d12dc10f7441d6ca61dc731c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:14:39 -0300 Subject: [PATCH 094/266] test(call): cover RTP and authenticated SRTP --- pkg/call/voip/media/rtp_srtp_test.go | 241 +++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 pkg/call/voip/media/rtp_srtp_test.go diff --git a/pkg/call/voip/media/rtp_srtp_test.go b/pkg/call/voip/media/rtp_srtp_test.go new file mode 100644 index 00000000..81562033 --- /dev/null +++ b/pkg/call/voip/media/rtp_srtp_test.go @@ -0,0 +1,241 @@ +package media + +import ( + "bytes" + "errors" + "testing" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +func testKeying(t *testing.T, fill byte, jid string) core.SRTPKeyingMaterial { + t.Helper() + material, err := DerivePerJIDSRTPKey(bytes.Repeat([]byte{fill}, 32), jid) + if err != nil { + t.Fatal(err) + } + return material +} + +func TestDerivePerJIDSRTPKey(t *testing.T) { + callKey := bytes.Repeat([]byte{0xab}, 32) + first, err := DerivePerJIDSRTPKey(callKey, "5511999999999:3@lid") + if err != nil { + t.Fatal(err) + } + defer first.Wipe() + second, err := DerivePerJIDSRTPKey(callKey, "5511999999999:3@lid") + if err != nil { + t.Fatal(err) + } + defer second.Wipe() + other, err := DerivePerJIDSRTPKey(callKey, "5511999999999:4@lid") + if err != nil { + t.Fatal(err) + } + defer other.Wipe() + + if len(first.MasterKey) != 16 || len(first.MasterSalt) != 14 { + t.Fatalf("unexpected keying lengths: key=%d salt=%d", len(first.MasterKey), len(first.MasterSalt)) + } + if !bytes.Equal(first.MasterKey, second.MasterKey) || !bytes.Equal(first.MasterSalt, second.MasterSalt) { + t.Fatal("per-device derivation is not deterministic") + } + if bytes.Equal(first.MasterKey, other.MasterKey) && bytes.Equal(first.MasterSalt, other.MasterSalt) { + t.Fatal("different device JIDs produced identical keying material") + } + if _, err = DerivePerJIDSRTPKey(callKey[:31], "device@lid"); err == nil { + t.Fatal("expected invalid call-key length error") + } + if _, err = DerivePerJIDSRTPKey(callKey, ""); err == nil { + t.Fatal("expected empty device JID error") + } +} + +func TestRTPPacketRoundTripWithExtensionAndPadding(t *testing.T) { + header := NewRTPHeader(core.PayloadTypeWhatsAppOpus, 0x1234, 0xdeadbeef, 0xcafebabe) + header.Marker = true + header.Extension = true + header.ExtensionProfile = 0xbede + header.ExtensionData = []byte{1, 2, 3, 4, 5, 6, 7, 8} + header.CSRC = []uint32{0x01020304, 0x05060708} + header.Padding = true + packet := &RTPPacket{Header: header, Payload: []byte{9, 8, 7, 6}, PaddingSize: 4} + + encoded, err := packet.Marshal() + if err != nil { + t.Fatal(err) + } + decoded, err := ParseRTPPacket(encoded) + if err != nil { + t.Fatal(err) + } + defer decoded.Wipe() + if decoded.Header.SequenceNumber != header.SequenceNumber || decoded.Header.Timestamp != header.Timestamp || decoded.Header.SSRC != header.SSRC { + t.Fatalf("header mismatch: %+v", decoded.Header) + } + if !bytes.Equal(decoded.Header.ExtensionData, header.ExtensionData) || !bytes.Equal(decoded.Payload, packet.Payload) { + t.Fatal("RTP extension or payload mismatch") + } + if decoded.PaddingSize != 4 || len(decoded.Header.CSRC) != 2 { + t.Fatalf("unexpected padding or CSRC count: padding=%d csrc=%d", decoded.PaddingSize, len(decoded.Header.CSRC)) + } +} + +func TestRTPRejectsMalformedFrames(t *testing.T) { + if _, err := ParseRTPPacket([]byte{0x80}); err == nil { + t.Fatal("expected short RTP frame error") + } + header := NewRTPHeader(120, 1, 2, 3) + header.Extension = true + header.ExtensionData = []byte{1, 2, 3} + if _, err := (&RTPPacket{Header: header, Payload: []byte{1}}).Marshal(); err == nil { + t.Fatal("expected unaligned extension error") + } + header = NewRTPHeader(120, 1, 2, 3) + header.Padding = true + if _, err := (&RTPPacket{Header: header, Payload: []byte{1}}).Marshal(); err == nil { + t.Fatal("expected missing padding-size error") + } +} + +func TestSRTPRoundTripAndAuthentication(t *testing.T) { + self := testKeying(t, 0x11, "self:0@lid") + peer := testKeying(t, 0x11, "peer:0@lid") + defer self.Wipe() + defer peer.Wipe() + + sender, err := NewSRTPSession(self, peer, core.SRTPSendAuthTagLen, core.SRTPRecvAuthTagLen) + if err != nil { + t.Fatal(err) + } + defer sender.Close() + receiver, err := NewSRTPSession(peer, self, core.SRTPRecvAuthTagLen, core.SRTPSendAuthTagLen) + if err != nil { + t.Fatal(err) + } + defer receiver.Close() + + rtp, err := NewWhatsAppOpusRTPSession(0xaabbccdd) + if err != nil { + t.Fatal(err) + } + payload := bytes.Repeat([]byte{0x42}, 40) + packet := rtp.CreatePacket(payload, true) + protected, err := sender.Protect(packet) + if err != nil { + t.Fatal(err) + } + plain, err := receiver.Unprotect(protected) + if err != nil { + t.Fatal(err) + } + defer plain.Wipe() + if !bytes.Equal(plain.Payload, payload) || plain.Header.SSRC != packet.Header.SSRC { + t.Fatal("SRTP roundtrip mismatch") + } + + tampered := append([]byte(nil), protected...) + tampered[len(tampered)-1] ^= 0xff + _, err = receiver.Unprotect(tampered) + var srtpErr *SRTPError + if !errors.As(err, &srtpErr) || srtpErr.Type != SRTPErrAuthFailed { + t.Fatalf("expected authentication error, got %v", err) + } + _, err = receiver.Unprotect(protected) + if !errors.As(err, &srtpErr) || srtpErr.Type != SRTPErrReplay { + t.Fatalf("expected replay error, got %v", err) + } +} + +func TestSRTPAcceptsAuthenticatedOutOfOrderPackets(t *testing.T) { + self := testKeying(t, 0x22, "self:0@lid") + peer := testKeying(t, 0x22, "peer:0@lid") + defer self.Wipe() + defer peer.Wipe() + sender, _ := NewSRTPSession(self, peer, 4, 4) + receiver, _ := NewSRTPSession(peer, self, 4, 4) + defer sender.Close() + defer receiver.Close() + + first := &RTPPacket{Header: NewRTPHeader(120, 100, 1000, 55), Payload: []byte("first")} + second := &RTPPacket{Header: NewRTPHeader(120, 101, 1960, 55), Payload: []byte("second")} + protectedFirst, err := sender.Protect(first) + if err != nil { + t.Fatal(err) + } + protectedSecond, err := sender.Protect(second) + if err != nil { + t.Fatal(err) + } + decodedSecond, err := receiver.Unprotect(protectedSecond) + if err != nil { + t.Fatal(err) + } + decodedSecond.Wipe() + decodedFirst, err := receiver.Unprotect(protectedFirst) + if err != nil { + t.Fatal(err) + } + defer decodedFirst.Wipe() + if string(decodedFirst.Payload) != "first" { + t.Fatalf("unexpected out-of-order payload: %q", decodedFirst.Payload) + } +} + +func TestSRTPSequenceRollover(t *testing.T) { + self := testKeying(t, 0x33, "self:0@lid") + peer := testKeying(t, 0x33, "peer:0@lid") + defer self.Wipe() + defer peer.Wipe() + sender, _ := NewSRTPSession(self, peer, 4, 4) + receiver, _ := NewSRTPSession(peer, self, 4, 4) + defer sender.Close() + defer receiver.Close() + + before := &RTPPacket{Header: NewRTPHeader(120, 0xffff, 1, 99), Payload: []byte{1}} + after := &RTPPacket{Header: NewRTPHeader(120, 0, 2, 99), Payload: []byte{2}} + protectedBefore, err := sender.Protect(before) + if err != nil { + t.Fatal(err) + } + protectedAfter, err := sender.Protect(after) + if err != nil { + t.Fatal(err) + } + decodedBefore, err := receiver.Unprotect(protectedBefore) + if err != nil { + t.Fatal(err) + } + decodedBefore.Wipe() + decodedAfter, err := receiver.Unprotect(protectedAfter) + if err != nil { + t.Fatal(err) + } + defer decodedAfter.Wipe() + if !bytes.Equal(decodedAfter.Payload, []byte{2}) { + t.Fatalf("unexpected rollover payload: %v", decodedAfter.Payload) + } +} + +func TestSRTPRejectsSendIndexReuseAndClosedContext(t *testing.T) { + self := testKeying(t, 0x44, "self:0@lid") + peer := testKeying(t, 0x44, "peer:0@lid") + defer self.Wipe() + defer peer.Wipe() + session, err := NewSRTPSession(self, peer, 4, 4) + if err != nil { + t.Fatal(err) + } + packet := &RTPPacket{Header: NewRTPHeader(120, 7, 1, 1), Payload: []byte{1}} + if _, err = session.Protect(packet); err != nil { + t.Fatal(err) + } + if _, err = session.Protect(packet); err == nil { + t.Fatal("expected duplicate send sequence to be rejected") + } + session.Close() + if _, err = session.Protect(&RTPPacket{Header: NewRTPHeader(120, 8, 2, 1), Payload: []byte{2}}); err == nil { + t.Fatal("expected closed session error") + } +} From adfca35b12a338b51618eba4dc4bec23371c022b Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:16:00 -0300 Subject: [PATCH 095/266] fix(call): reuse core buffer wipe helper --- pkg/call/voip/core/srtp.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pkg/call/voip/core/srtp.go b/pkg/call/voip/core/srtp.go index e5d2793e..028396c6 100644 --- a/pkg/call/voip/core/srtp.go +++ b/pkg/call/voip/core/srtp.go @@ -29,9 +29,3 @@ func (m *SRTPKeyingMaterial) Wipe() { m.MasterKey = nil m.MasterSalt = nil } - -func zeroBytes(value []byte) { - for index := range value { - value[index] = 0 - } -} From 61ce87b479f6ac6a82d94faa934198d8c92c9be4 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:16:20 -0300 Subject: [PATCH 096/266] feat(call): derive SRTP keying from private call state --- pkg/call/voip/incoming/srtp_bridge.go | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 pkg/call/voip/incoming/srtp_bridge.go diff --git a/pkg/call/voip/incoming/srtp_bridge.go b/pkg/call/voip/incoming/srtp_bridge.go new file mode 100644 index 00000000..ea22cbee --- /dev/null +++ b/pkg/call/voip/incoming/srtp_bridge.go @@ -0,0 +1,51 @@ +package incoming + +import ( + "fmt" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + call_media "github.com/evolution-foundation/evolution-go/pkg/call/voip/media" +) + +func (s *session) deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) { + if callID == "" { + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call ID is empty") + } + if selfDeviceJID == "" || peerDeviceJID == "" { + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("SRTP device JIDs are incomplete") + } + + s.mu.RLock() + material := s.materials[callID] + if material == nil || len(material.callKey) == 0 { + s.mu.RUnlock() + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call %s has no private encryption key", callID) + } + callKey := append([]byte(nil), material.callKey...) + s.mu.RUnlock() + defer zeroBytes(callKey) + + sendKeying, err := call_media.DerivePerJIDSRTPKey(callKey, selfDeviceJID) + if err != nil { + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive send SRTP keying: %w", err) + } + receiveKeying, err := call_media.DerivePerJIDSRTPKey(callKey, peerDeviceJID) + if err != nil { + sendKeying.Wipe() + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive receive SRTP keying: %w", err) + } + return sendKeying, receiveKeying, nil +} + +// SRTPKeying derives per-device keying material without exposing the private +// WhatsApp call key outside the negotiation registry. The caller owns both +// returned values and must wipe them after constructing the SRTP session. +func (r *Registry) SRTPKeying(instanceID, callID, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) { + r.mu.RLock() + s := r.sessions[instanceID] + r.mu.RUnlock() + if s == nil { + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call runtime is not attached for instance %s", instanceID) + } + return s.deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID) +} From 948f9cb4e3b39ebcb7ca7d45e37f2ee46f7ad5b1 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:17:03 -0300 Subject: [PATCH 097/266] feat(call): add per-call RTP SRTP packet registry --- pkg/call/voip/media/packet_registry.go | 308 +++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 pkg/call/voip/media/packet_registry.go diff --git a/pkg/call/voip/media/packet_registry.go b/pkg/call/voip/media/packet_registry.go new file mode 100644 index 00000000..a845cdf4 --- /dev/null +++ b/pkg/call/voip/media/packet_registry.go @@ -0,0 +1,308 @@ +package media + +import ( + "errors" + "fmt" + "sync" + + call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" +) + +var ( + ErrPacketSessionNotReady = errors.New("RTP/SRTP packet session is not ready") + ErrNonRTPFrame = errors.New("relay frame is not RTP/SRTP") +) + +type PacketSource interface { + RelayData(instanceID, callID string) (*core.RelayData, bool) + State(instanceID, callID string) (*call_state.Info, bool) + SRTPKeying(instanceID, callID, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) +} + +type packetSession struct { + srtp *SRTPSession + rtp *RTPSession + selfSSRC uint32 + peerSSRC uint32 +} + +func newPacketSession(sendKeying, receiveKeying core.SRTPKeyingMaterial, selfSSRC, peerSSRC uint32) (*packetSession, error) { + if selfSSRC == 0 || peerSSRC == 0 { + return nil, fmt.Errorf("RTP SSRC values must be non-zero") + } + srtp, err := NewSRTPSession(sendKeying, receiveKeying, core.SRTPSendAuthTagLen, core.SRTPRecvAuthTagLen) + if err != nil { + return nil, err + } + rtp, err := NewWhatsAppOpusRTPSession(selfSSRC) + if err != nil { + srtp.Close() + return nil, err + } + return &packetSession{srtp: srtp, rtp: rtp, selfSSRC: selfSSRC, peerSSRC: peerSSRC}, nil +} + +func (s *packetSession) protectOpus(payload []byte, durationSamples uint32, marker bool) ([]byte, error) { + if s == nil || s.srtp == nil || s.rtp == nil { + return nil, ErrPacketSessionNotReady + } + packet := s.rtp.CreatePacketWithDuration(payload, durationSamples, marker) + defer packet.Wipe() + return s.srtp.Protect(packet) +} + +func (s *packetSession) unprotect(frame []byte) (*RTPPacket, error) { + if s == nil || s.srtp == nil { + return nil, ErrPacketSessionNotReady + } + packet, err := s.srtp.Unprotect(frame) + if err != nil { + return nil, err + } + if packet.Header.SSRC != s.peerSSRC { + got := packet.Header.SSRC + packet.Wipe() + return nil, fmt.Errorf("unexpected RTP SSRC: got %d, want %d", got, s.peerSSRC) + } + if packet.Header.PayloadType != core.PayloadTypeWhatsAppOpus { + got := packet.Header.PayloadType + packet.Wipe() + return nil, fmt.Errorf("unexpected RTP payload type: %d", got) + } + return packet, nil +} + +func (s *packetSession) close() { + if s == nil { + return + } + if s.srtp != nil { + s.srtp.Close() + } + s.srtp = nil + s.rtp = nil + s.selfSSRC = 0 + s.peerSSRC = 0 +} + +type PacketRegistry struct { + mu sync.RWMutex + source PacketSource + clients map[string]*whatsmeow.Client + sessions map[string]map[string]*packetSession + onRTP func(instanceID, callID string, packet *RTPPacket) +} + +func NewPacketRegistry(source PacketSource) *PacketRegistry { + return &PacketRegistry{ + source: source, + clients: make(map[string]*whatsmeow.Client), + sessions: make(map[string]map[string]*packetSession), + } +} + +func (r *PacketRegistry) SetOnRTP(callback func(instanceID, callID string, packet *RTPPacket)) { + r.mu.Lock() + r.onRTP = callback + r.mu.Unlock() +} + +func (r *PacketRegistry) Attach(instanceID string, client *whatsmeow.Client) { + if r == nil || instanceID == "" || client == nil { + return + } + r.mu.Lock() + previous := r.clients[instanceID] + r.clients[instanceID] = client + if previous != nil && previous != client { + sessions := r.sessions[instanceID] + delete(r.sessions, instanceID) + r.mu.Unlock() + closePacketSessions(sessions) + return + } + r.mu.Unlock() +} + +func (r *PacketRegistry) Prepare(instanceID, callID string) error { + if r == nil || r.source == nil { + return ErrPacketSessionNotReady + } + r.mu.RLock() + client := r.clients[instanceID] + if calls := r.sessions[instanceID]; calls != nil && calls[callID] != nil { + r.mu.RUnlock() + return nil + } + r.mu.RUnlock() + if client == nil { + return fmt.Errorf("packet runtime is not attached for instance %s", instanceID) + } + + state, ok := r.source.State(instanceID, callID) + if !ok || state == nil { + return fmt.Errorf("call %s has no private state", callID) + } + relayData, ok := r.source.RelayData(instanceID, callID) + if !ok || relayData == nil { + return fmt.Errorf("call %s has no relay data", callID) + } + defer core.ZeroRelayData(relayData) + + ownJID := ownClientJID(client) + peerJID, err := types.ParseJID(state.PeerJID) + if err != nil || ownJID.IsEmpty() || peerJID.IsEmpty() { + return fmt.Errorf("resolve RTP participants for call %s", callID) + } + selfDevice, peerDevice := selectDeviceJIDs(relayData.ParticipantJIDs, ownJID, peerJID) + selfSSRC, err := GenerateSecureSSRC(callID, selfDevice, 0) + if err != nil { + return err + } + peerSSRC, err := GenerateSecureSSRC(callID, peerDevice, 0) + if err != nil { + return err + } + return r.PrepareWithDevices(instanceID, callID, selfDevice, peerDevice, selfSSRC, peerSSRC) +} + +func (r *PacketRegistry) PrepareWithDevices(instanceID, callID, selfDeviceJID, peerDeviceJID string, selfSSRC, peerSSRC uint32) error { + if r == nil || r.source == nil { + return ErrPacketSessionNotReady + } + sendKeying, receiveKeying, err := r.source.SRTPKeying(instanceID, callID, selfDeviceJID, peerDeviceJID) + if err != nil { + return err + } + defer sendKeying.Wipe() + defer receiveKeying.Wipe() + + candidate, err := newPacketSession(sendKeying, receiveKeying, selfSSRC, peerSSRC) + if err != nil { + return err + } + + r.mu.Lock() + calls := r.sessions[instanceID] + if calls == nil { + calls = make(map[string]*packetSession) + r.sessions[instanceID] = calls + } + previous := calls[callID] + calls[callID] = candidate + r.mu.Unlock() + if previous != nil { + previous.close() + } + return nil +} + +func (r *PacketRegistry) ProtectOpus(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) ([]byte, error) { + session, err := r.packetSession(instanceID, callID, true) + if err != nil { + return nil, err + } + return session.protectOpus(payload, durationSamples, marker) +} + +func (r *PacketRegistry) Unprotect(instanceID, callID string, frame []byte) (*RTPPacket, error) { + if len(frame) < 2 || frame[0]&0xc0 != 0x80 { + return nil, ErrNonRTPFrame + } + session, err := r.packetSession(instanceID, callID, true) + if err != nil { + return nil, err + } + return session.unprotect(frame) +} + +func (r *PacketRegistry) Handle(instanceID, callID string, frame []byte) error { + packet, err := r.Unprotect(instanceID, callID, frame) + if err != nil { + return err + } + defer packet.Wipe() + r.mu.RLock() + callback := r.onRTP + r.mu.RUnlock() + if callback != nil { + callback(instanceID, callID, packet) + } + return nil +} + +func (r *PacketRegistry) packetSession(instanceID, callID string, lazyPrepare bool) (*packetSession, error) { + r.mu.RLock() + calls := r.sessions[instanceID] + session := calls[callID] + r.mu.RUnlock() + if session != nil { + return session, nil + } + if lazyPrepare { + if err := r.Prepare(instanceID, callID); err != nil { + return nil, err + } + r.mu.RLock() + session = r.sessions[instanceID][callID] + r.mu.RUnlock() + if session != nil { + return session, nil + } + } + return nil, ErrPacketSessionNotReady +} + +func (r *PacketRegistry) Remove(instanceID, callID string) { + if r == nil { + return + } + r.mu.Lock() + calls := r.sessions[instanceID] + session := calls[callID] + delete(calls, callID) + if len(calls) == 0 { + delete(r.sessions, instanceID) + } + r.mu.Unlock() + if session != nil { + session.close() + } +} + +func (r *PacketRegistry) Close(instanceID string) { + if r == nil { + return + } + r.mu.Lock() + delete(r.clients, instanceID) + sessions := r.sessions[instanceID] + delete(r.sessions, instanceID) + r.mu.Unlock() + closePacketSessions(sessions) +} + +func closePacketSessions(sessions map[string]*packetSession) { + for callID, session := range sessions { + if session != nil { + session.close() + } + delete(sessions, callID) + } +} + +func ownClientJID(client *whatsmeow.Client) types.JID { + if client == nil { + return types.JID{} + } + socket := wa.NewSocket(client) + jid := socket.OwnLID() + if jid.IsEmpty() { + jid = socket.OwnPN() + } + return jid +} From bab19a85bb9263ed8decab91b27d3159a479ae88 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:17:17 -0300 Subject: [PATCH 098/266] feat(call): expose per-call relay broadcast --- pkg/call/voip/media/relay_broadcast.go | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 pkg/call/voip/media/relay_broadcast.go diff --git a/pkg/call/voip/media/relay_broadcast.go b/pkg/call/voip/media/relay_broadcast.go new file mode 100644 index 00000000..3cee1125 --- /dev/null +++ b/pkg/call/voip/media/relay_broadcast.go @@ -0,0 +1,41 @@ +package media + +import "fmt" + +// Broadcast sends one already-framed packet to every open relay connection for +// the selected call. The caller retains ownership of the supplied buffer. +func (r *RelayRegistry) Broadcast(instanceID, callID string, data []byte) error { + if r == nil { + return fmt.Errorf("relay registry is nil") + } + r.mu.RLock() + session := r.sessions[instanceID] + r.mu.RUnlock() + if session == nil { + return fmt.Errorf("relay runtime is not attached for instance %s", instanceID) + } + + session.mu.Lock() + relay := session.transports[callID] + session.mu.Unlock() + if relay == nil { + return fmt.Errorf("call %s has no relay transport", callID) + } + return relay.Broadcast(data) +} + +func (r *RelayRegistry) HasConnection(instanceID, callID string) bool { + if r == nil { + return false + } + r.mu.RLock() + session := r.sessions[instanceID] + r.mu.RUnlock() + if session == nil { + return false + } + session.mu.Lock() + relay := session.transports[callID] + session.mu.Unlock() + return relay != nil && relay.HasConnection() +} From ae4cf185882515ee4322cfeb3fe43b00ab632c61 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:17:50 -0300 Subject: [PATCH 099/266] feat(call): connect RTP SRTP sessions to relay lifecycle --- pkg/call/lifecycle/coordinator.go | 49 +++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go index 28820778..2be73582 100644 --- a/pkg/call/lifecycle/coordinator.go +++ b/pkg/call/lifecycle/coordinator.go @@ -4,6 +4,7 @@ package lifecycle import ( "context" + "errors" "sync" call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" @@ -21,22 +22,34 @@ type Coordinator struct { runtimes *call_runtime.Registry incoming *call_incoming.Registry relays *call_media.RelayRegistry + packets *call_media.PacketRegistry incomingEnabled map[string]bool } func NewCoordinator() *Coordinator { incoming := call_incoming.NewRegistry() + packets := call_media.NewPacketRegistry(incoming) coordinator := &Coordinator{ runtimes: call_runtime.NewRegistry(), incoming: incoming, + packets: packets, incomingEnabled: make(map[string]bool), } coordinator.relays = call_media.NewRelayRegistry(incoming, nil, nil) coordinator.relays.SetOnConnected(func(instanceID, callID string) { + if err := coordinator.packets.Prepare(instanceID, callID); err != nil { + return + } if runtime, ok := coordinator.runtimes.Get(instanceID); ok { runtime.Transition(callID, "", "", call_runtime.StateActive, nil, "") } }) + coordinator.relays.SetOnPacket(func(instanceID, callID string, packet []byte) { + err := coordinator.packets.Handle(instanceID, callID, packet) + if errors.Is(err, call_media.ErrNonRTPFrame) || errors.Is(err, call_media.ErrPacketSessionNotReady) { + return + } + }) return coordinator } @@ -54,11 +67,12 @@ func (c *Coordinator) AttachClient(instanceID string, client *whatsmeow.Client, c.runtimes.Attach(instanceID, client) c.incoming.Attach(instanceID, client, prepareIncoming) + c.packets.Attach(instanceID, client) c.relays.Attach(instanceID, client) } -// DetachClient removes handlers, relay connections, configuration and private -// call keys before the WhatsApp client is discarded. +// DetachClient removes handlers, relay connections, packet contexts, +// configuration and private call keys before the WhatsApp client is discarded. func (c *Coordinator) DetachClient(instanceID string) { if c == nil || instanceID == "" { return @@ -67,6 +81,7 @@ func (c *Coordinator) DetachClient(instanceID string) { delete(c.incomingEnabled, instanceID) c.mu.Unlock() c.relays.Close(instanceID) + c.packets.Close(instanceID) c.runtimes.Remove(instanceID) c.incoming.Close(instanceID) } @@ -86,6 +101,7 @@ func (c *Coordinator) Attach(instanceID string, client *whatsmeow.Client) { prepareIncoming = true } c.incoming.Attach(instanceID, client, prepareIncoming) + c.packets.Attach(instanceID, client) c.relays.Attach(instanceID, client) } @@ -138,11 +154,34 @@ func (c *Coordinator) TerminateIncoming(ctx context.Context, instanceID, callID return err } c.relays.Remove(instanceID, callID) + c.packets.Remove(instanceID, callID) return nil } +// SendOpus protects one encoded Opus frame as SRTP and broadcasts it through +// the currently connected WhatsApp relays. It is an internal media boundary; +// no HTTP endpoint exposes raw audio frames in this milestone. +func (c *Coordinator) SendOpus(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { + if c == nil { + return call_media.ErrPacketSessionNotReady + } + protected, err := c.packets.ProtectOpus(instanceID, callID, payload, durationSamples, marker) + if err != nil { + return err + } + defer wipe(protected) + return c.relays.Broadcast(instanceID, callID, protected) +} + +func (c *Coordinator) SetOnRTP(callback func(instanceID, callID string, packet *call_media.RTPPacket)) { + if c != nil { + c.packets.SetOnRTP(callback) + } +} + func (c *Coordinator) RemovePrivate(instanceID, callID string) { c.relays.Remove(instanceID, callID) + c.packets.Remove(instanceID, callID) c.incoming.Remove(instanceID, callID) } @@ -151,3 +190,9 @@ func (c *Coordinator) RemovePrivate(instanceID, callID string) { func (c *Coordinator) RemoveIncoming(instanceID, callID string) { c.RemovePrivate(instanceID, callID) } + +func wipe(value []byte) { + for index := range value { + value[index] = 0 + } +} From a373d0f1609a7f2e040aa98b454acb285291c6be Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:19:12 -0300 Subject: [PATCH 100/266] test(call): validate per-call RTP SRTP registry --- pkg/call/voip/media/packet_registry_test.go | 174 ++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 pkg/call/voip/media/packet_registry_test.go diff --git a/pkg/call/voip/media/packet_registry_test.go b/pkg/call/voip/media/packet_registry_test.go new file mode 100644 index 00000000..6cfc5cf1 --- /dev/null +++ b/pkg/call/voip/media/packet_registry_test.go @@ -0,0 +1,174 @@ +package media + +import ( + "bytes" + "errors" + "testing" + + call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +type fakePacketSource struct { + callKey []byte +} + +func (f *fakePacketSource) RelayData(string, string) (*core.RelayData, bool) { + return nil, false +} + +func (f *fakePacketSource) State(string, string) (*call_state.Info, bool) { + return nil, false +} + +func (f *fakePacketSource) SRTPKeying(_, _, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) { + send, err := DerivePerJIDSRTPKey(f.callKey, selfDeviceJID) + if err != nil { + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, err + } + receive, err := DerivePerJIDSRTPKey(f.callKey, peerDeviceJID) + if err != nil { + send.Wipe() + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, err + } + return send, receive, nil +} + +func TestPacketRegistryProtectsAndUnprotectsOpus(t *testing.T) { + callKey := bytes.Repeat([]byte{0x5a}, 32) + source := &fakePacketSource{callKey: callKey} + registry := NewPacketRegistry(source) + const ( + instanceID = "instance-1" + callID = "call-1" + selfDevice = "5511000000000:1@lid" + peerDevice = "5511999999999:2@lid" + selfSSRC = uint32(0x11223344) + peerSSRC = uint32(0x55667788) + ) + if err := registry.PrepareWithDevices(instanceID, callID, selfDevice, peerDevice, selfSSRC, peerSSRC); err != nil { + t.Fatal(err) + } + defer registry.Close(instanceID) + + peerSend, err := DerivePerJIDSRTPKey(callKey, peerDevice) + if err != nil { + t.Fatal(err) + } + defer peerSend.Wipe() + peerReceive, err := DerivePerJIDSRTPKey(callKey, selfDevice) + if err != nil { + t.Fatal(err) + } + defer peerReceive.Wipe() + peerSession, err := NewSRTPSession(peerSend, peerReceive, core.SRTPRecvAuthTagLen, core.SRTPSendAuthTagLen) + if err != nil { + t.Fatal(err) + } + defer peerSession.Close() + peerRTP, err := NewWhatsAppOpusRTPSession(peerSSRC) + if err != nil { + t.Fatal(err) + } + + incomingPayload := []byte("peer opus frame") + incomingFrame, err := peerSession.Protect(peerRTP.CreatePacket(incomingPayload, true)) + if err != nil { + t.Fatal(err) + } + decoded, err := registry.Unprotect(instanceID, callID, incomingFrame) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded.Payload, incomingPayload) || decoded.Header.SSRC != peerSSRC { + t.Fatalf("unexpected decoded packet: ssrc=%d payload=%q", decoded.Header.SSRC, decoded.Payload) + } + decoded.Wipe() + + outgoingPayload := []byte("local opus frame") + outgoingFrame, err := registry.ProtectOpus(instanceID, callID, outgoingPayload, 960, true) + if err != nil { + t.Fatal(err) + } + peerDecoded, err := peerSession.Unprotect(outgoingFrame) + if err != nil { + t.Fatal(err) + } + defer peerDecoded.Wipe() + if !bytes.Equal(peerDecoded.Payload, outgoingPayload) || peerDecoded.Header.SSRC != selfSSRC { + t.Fatalf("unexpected peer packet: ssrc=%d payload=%q", peerDecoded.Header.SSRC, peerDecoded.Payload) + } +} + +func TestPacketRegistryHandleInvokesCallbackAndRejectsNonRTP(t *testing.T) { + callKey := bytes.Repeat([]byte{0x6b}, 32) + registry := NewPacketRegistry(&fakePacketSource{callKey: callKey}) + const ( + instanceID = "instance-2" + callID = "call-2" + selfDevice = "self:1@lid" + peerDevice = "peer:2@lid" + selfSSRC = uint32(101) + peerSSRC = uint32(202) + ) + if err := registry.PrepareWithDevices(instanceID, callID, selfDevice, peerDevice, selfSSRC, peerSSRC); err != nil { + t.Fatal(err) + } + defer registry.Close(instanceID) + + peerSend, _ := DerivePerJIDSRTPKey(callKey, peerDevice) + peerReceive, _ := DerivePerJIDSRTPKey(callKey, selfDevice) + defer peerSend.Wipe() + defer peerReceive.Wipe() + peerSession, err := NewSRTPSession(peerSend, peerReceive, 4, 4) + if err != nil { + t.Fatal(err) + } + defer peerSession.Close() + peerRTP, _ := NewWhatsAppOpusRTPSession(peerSSRC) + frame, err := peerSession.Protect(peerRTP.CreatePacket([]byte{1, 2, 3}, false)) + if err != nil { + t.Fatal(err) + } + + called := false + registry.SetOnRTP(func(gotInstance, gotCall string, packet *RTPPacket) { + called = true + if gotInstance != instanceID || gotCall != callID || !bytes.Equal(packet.Payload, []byte{1, 2, 3}) { + t.Fatalf("unexpected callback data: %s %s %v", gotInstance, gotCall, packet.Payload) + } + }) + if err = registry.Handle(instanceID, callID, frame); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("RTP callback was not invoked") + } + if err = registry.Handle(instanceID, callID, []byte{0x00, 0x01}); !errors.Is(err, ErrNonRTPFrame) { + t.Fatalf("expected non-RTP error, got %v", err) + } +} + +func TestPacketRegistryRejectsUnexpectedPeerSSRC(t *testing.T) { + callKey := bytes.Repeat([]byte{0x7c}, 32) + registry := NewPacketRegistry(&fakePacketSource{callKey: callKey}) + if err := registry.PrepareWithDevices("instance", "call", "self@lid", "peer@lid", 11, 22); err != nil { + t.Fatal(err) + } + defer registry.Close("instance") + + peerSend, _ := DerivePerJIDSRTPKey(callKey, "peer@lid") + peerReceive, _ := DerivePerJIDSRTPKey(callKey, "self@lid") + defer peerSend.Wipe() + defer peerReceive.Wipe() + peerSession, _ := NewSRTPSession(peerSend, peerReceive, 4, 4) + defer peerSession.Close() + wrongRTP, _ := NewWhatsAppOpusRTPSession(99) + frame, err := peerSession.Protect(wrongRTP.CreatePacket([]byte{1}, false)) + if err != nil { + t.Fatal(err) + } + if _, err = registry.Unprotect("instance", "call", frame); err == nil { + t.Fatal("expected unexpected SSRC error") + } +} From dc9b724f083390f228b6fbab3a94ef209a1de99e Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:19:25 -0300 Subject: [PATCH 101/266] test(call): verify private SRTP key derivation --- pkg/call/voip/incoming/srtp_bridge_test.go | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 pkg/call/voip/incoming/srtp_bridge_test.go diff --git a/pkg/call/voip/incoming/srtp_bridge_test.go b/pkg/call/voip/incoming/srtp_bridge_test.go new file mode 100644 index 00000000..cba3fb3f --- /dev/null +++ b/pkg/call/voip/incoming/srtp_bridge_test.go @@ -0,0 +1,65 @@ +package incoming + +import ( + "bytes" + "testing" + + call_media "github.com/evolution-foundation/evolution-go/pkg/call/voip/media" + "go.mau.fi/whatsmeow/types" +) + +func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) { + callKey := bytes.Repeat([]byte{0x42}, 32) + session := newSession(nil) + peer := types.NewJID("5511999999999", types.DefaultUserServer) + creator := types.NewJID("5511000000000", types.DefaultUserServer) + session.storeOutgoing("call-1", callKey, peer, creator, false, nil) + defer session.clear() + + send, receive, err := session.deriveSRTPKeying("call-1", "self:1@lid", "peer:2@lid") + if err != nil { + t.Fatal(err) + } + defer send.Wipe() + defer receive.Wipe() + + wantSend, err := call_media.DerivePerJIDSRTPKey(callKey, "self:1@lid") + if err != nil { + t.Fatal(err) + } + defer wantSend.Wipe() + wantReceive, err := call_media.DerivePerJIDSRTPKey(callKey, "peer:2@lid") + if err != nil { + t.Fatal(err) + } + defer wantReceive.Wipe() + + if !bytes.Equal(send.MasterKey, wantSend.MasterKey) || !bytes.Equal(send.MasterSalt, wantSend.MasterSalt) { + t.Fatal("send SRTP keying mismatch") + } + if !bytes.Equal(receive.MasterKey, wantReceive.MasterKey) || !bytes.Equal(receive.MasterSalt, wantReceive.MasterSalt) { + t.Fatal("receive SRTP keying mismatch") + } + + zeroBytes(send.MasterKey) + zeroBytes(send.MasterSalt) + again, againReceive, err := session.deriveSRTPKeying("call-1", "self:1@lid", "peer:2@lid") + if err != nil { + t.Fatal(err) + } + defer again.Wipe() + defer againReceive.Wipe() + if bytes.Equal(again.MasterKey, make([]byte, len(again.MasterKey))) { + t.Fatal("wiping returned material modified the stored call key") + } +} + +func TestSessionRejectsMissingSRTPMaterial(t *testing.T) { + session := newSession(nil) + if _, _, err := session.deriveSRTPKeying("missing", "self@lid", "peer@lid"); err == nil { + t.Fatal("expected missing call-key error") + } + if _, _, err := session.deriveSRTPKeying("", "self@lid", "peer@lid"); err == nil { + t.Fatal("expected empty call ID error") + } +} From de8e35ac56966846a6277c88dae281f4cd7c0fdb Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:20:45 -0300 Subject: [PATCH 102/266] fix(call): synchronize RTP SRTP packet teardown --- pkg/call/voip/media/packet_registry.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/pkg/call/voip/media/packet_registry.go b/pkg/call/voip/media/packet_registry.go index a845cdf4..ecb11157 100644 --- a/pkg/call/voip/media/packet_registry.go +++ b/pkg/call/voip/media/packet_registry.go @@ -24,6 +24,7 @@ type PacketSource interface { } type packetSession struct { + mu sync.RWMutex srtp *SRTPSession rtp *RTPSession selfSSRC uint32 @@ -47,7 +48,12 @@ func newPacketSession(sendKeying, receiveKeying core.SRTPKeyingMaterial, selfSSR } func (s *packetSession) protectOpus(payload []byte, durationSamples uint32, marker bool) ([]byte, error) { - if s == nil || s.srtp == nil || s.rtp == nil { + if s == nil { + return nil, ErrPacketSessionNotReady + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.srtp == nil || s.rtp == nil { return nil, ErrPacketSessionNotReady } packet := s.rtp.CreatePacketWithDuration(payload, durationSamples, marker) @@ -56,7 +62,12 @@ func (s *packetSession) protectOpus(payload []byte, durationSamples uint32, mark } func (s *packetSession) unprotect(frame []byte) (*RTPPacket, error) { - if s == nil || s.srtp == nil { + if s == nil { + return nil, ErrPacketSessionNotReady + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.srtp == nil { return nil, ErrPacketSessionNotReady } packet, err := s.srtp.Unprotect(frame) @@ -80,6 +91,8 @@ func (s *packetSession) close() { if s == nil { return } + s.mu.Lock() + defer s.mu.Unlock() if s.srtp != nil { s.srtp.Close() } From 101ba000e64afd178442302f87670b1d3c54538a Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:20:56 -0300 Subject: [PATCH 103/266] test(call): cover concurrent SRTP packet teardown --- .../voip/media/packet_registry_race_test.go | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 pkg/call/voip/media/packet_registry_race_test.go diff --git a/pkg/call/voip/media/packet_registry_race_test.go b/pkg/call/voip/media/packet_registry_race_test.go new file mode 100644 index 00000000..3d91e485 --- /dev/null +++ b/pkg/call/voip/media/packet_registry_race_test.go @@ -0,0 +1,36 @@ +package media + +import ( + "bytes" + "sync" + "testing" +) + +func TestPacketRegistryConcurrentProtectAndRemove(t *testing.T) { + registry := NewPacketRegistry(&fakePacketSource{callKey: bytes.Repeat([]byte{0x8d}, 32)}) + const ( + instanceID = "race-instance" + callID = "race-call" + ) + if err := registry.PrepareWithDevices(instanceID, callID, "self@lid", "peer@lid", 303, 404); err != nil { + t.Fatal(err) + } + + var wait sync.WaitGroup + wait.Add(2) + go func() { + defer wait.Done() + for index := 0; index < 200; index++ { + _, _ = registry.ProtectOpus(instanceID, callID, []byte{byte(index)}, 960, index == 0) + } + }() + go func() { + defer wait.Done() + for index := 0; index < 20; index++ { + registry.Remove(instanceID, callID) + _ = registry.PrepareWithDevices(instanceID, callID, "self@lid", "peer@lid", 303, 404) + } + }() + wait.Wait() + registry.Close(instanceID) +} From 51d2816bf0d23391c6247a2bb3689c1cb1ef0a9a Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:22:16 -0300 Subject: [PATCH 104/266] docs(call): document RTP SRTP packet pipeline --- docs/wiki/guias-api/api-calls-experimental.md | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md index 19c111bf..18ec03a8 100644 --- a/docs/wiki/guias-api/api-calls-experimental.md +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -2,7 +2,7 @@ Esta branch adiciona a integração experimental WaCalls/AstraCalls ao Evolution Go. -> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante experimental também consegue negociar DataChannels com os relays usando Pion. RTP/SRTP e áudio ainda não foram conectados. Não use em produção. +> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante `voip_pion` negocia DataChannels com os relays e processa pacotes RTP/SRTP autenticados. Ainda não há áudio reproduzível porque codecs, PCM e a ponte WebRTC não foram conectados. Não use em produção. Todas as rotas usam a autenticação normal da instância do Evolution. @@ -12,7 +12,7 @@ Todas as rotas usam a autenticação normal da instância do Evolution. GET /call/status ``` -Os monitores de chamada são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels e material privado são removidos antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento. +Os monitores são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels, sessões RTP/SRTP e material privado são removidos antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento. Exemplo de resposta: @@ -24,7 +24,7 @@ Exemplo de resposta: } ``` -Chaves de chamada, JIDs internos de dispositivos, tokens de relay e outros dados privados não fazem parte dessa resposta. Eles ficam somente em memória e são apagados quando a chamada termina, é rejeitada ou a sessão é desconectada. +Chaves de chamada, JIDs internos de dispositivos, chaves SRTP, tokens de relay e outros dados privados não fazem parte dessa resposta. Eles ficam somente em memória e são apagados quando a chamada termina, é rejeitada ou a sessão é desconectada. Instâncias configuradas com rejeição automática continuam rastreando o estado público da chamada. Elas não descriptografam ofertas recebidas nem enviam `preaccept`, mas continuam podendo iniciar chamadas e armazenar sua negociação privada de saída. @@ -80,7 +80,7 @@ O runtime descriptografa a chave recebida usando a sessão Signal já autenticad } ``` -`CallAccept` não marca mais a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que um DataChannel de relay abre e o callback `media_connected` é aplicado. +`CallAccept` não marca a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que o DataChannel abre e a sessão RTP/SRTP por chamada é criada com sucesso. Se a preparação criptográfica do evento ainda não terminou, a API retorna um erro informando que a chamada ainda não está pronta para aceite. @@ -91,7 +91,7 @@ DELETE /call/{callId} apikey: INSTANCE_TOKEN ``` -A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove chave, candidatos, DataChannels e demais dados privados da chamada. +A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove chave, candidatos, DataChannels, contextos RTP/SRTP e demais dados privados da chamada. ## Rejeitar uma chamada recebida @@ -153,6 +153,27 @@ A implementação experimental Pion inclui: - timeout, fechamento e limpeza de buffers; - SSRC determinístico derivado de `callId` e JID do dispositivo. +## RTP e SRTP + +O caminho de pacotes agora inclui: + +- RTP versão 2 com CSRC, extensões e padding validados; +- gerador concorrente de sequência e timestamp para payload type `120`; +- derivação HKDF-SHA256 por JID de dispositivo a partir da chave privada de 32 bytes da chamada; +- chave mestra AES de 16 bytes e salt de 14 bytes; +- AES-CTR para proteção do payload; +- autenticação HMAC-SHA1 truncada em quatro bytes; +- verificação da autenticação antes da descriptografia; +- rollover counter para a transição de sequência `65535 → 0`; +- janela antirreplay de 64 pacotes; +- suporte a pacotes autenticados fora de ordem dentro da janela; +- rejeição de reutilização do índice SRTP no envio; +- validação do SSRC remoto e do payload type Opus; +- sessão independente por `callId`; +- limpeza sincronizada durante término, rejeição, logout ou reconexão. + +O `Coordinator` já possui uma fronteira interna para proteger um frame Opus e transmiti-lo pelos relays. Ela ainda não é exposta em HTTP porque falta conectar encoder/decoder e PCM. + A build padrão continua usando um transportador sem rede. Para compilar a variante experimental: ```bash @@ -168,11 +189,11 @@ go test -race -tags=voip_pion ./pkg/call/... ## Limitações atuais -- sem áudio bidirecional; -- os frames recebidos pelo DataChannel ainda não são processados por uma sessão RTP/SRTP; -- sem derivação e instalação das chaves SRTP; -- sem codecs Opus ou MLow conectados ao runtime; +- sem áudio bidirecional reproduzível; +- sem encoder/decoder Opus ou MLow conectado ao runtime; +- sem captura e reprodução PCM; - sem WebRTC para navegador; +- o caminho interno de envio Opus ainda não possui endpoint público; - a conexão real com um relay WhatsApp ainda precisa ser validada de ponta a ponta com uma conta conectada; - as chaves ficam somente em memória e não sobrevivem a reinícios; - API e formatos podem mudar enquanto o PR estiver em rascunho. From e8d25b66be2c697eacf15a247735a555d0b75c63 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:41:03 -0300 Subject: [PATCH 105/266] chore(call): stage MLow codec port --- tools/port_mlow_codec.py | 254 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 tools/port_mlow_codec.py diff --git a/tools/port_mlow_codec.py b/tools/port_mlow_codec.py new file mode 100644 index 00000000..00f1f769 --- /dev/null +++ b/tools/port_mlow_codec.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path + +UPSTREAM_URL = "https://github.com/JotaDev66/WaCalls.git" +UPSTREAM_COMMIT = "edeb31f0427aba896639db503153b777a405eccf" +ROOT = Path(__file__).resolve().parents[1] +TARGET = ROOT / "pkg/call/voip/media/mlow" +LICENSE_HEADER = "// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.\n" + + +def run(*args: str, cwd: Path | None = None) -> None: + subprocess.run(args, cwd=cwd, check=True) + + +def add_license_header(source: str) -> str: + if "JotaDev66/WaCalls" in source[:300]: + return source + return LICENSE_HEADER + source + + +with tempfile.TemporaryDirectory(prefix="wacalls-mlow-") as tmp: + checkout = Path(tmp) / "WaCalls" + run("git", "clone", "--filter=blob:none", "--no-checkout", UPSTREAM_URL, str(checkout)) + run("git", "-C", str(checkout), "fetch", "--depth", "1", "origin", UPSTREAM_COMMIT) + run("git", "-C", str(checkout), "checkout", "--detach", UPSTREAM_COMMIT) + + source_dir = checkout / "internal/voip/media/mlow" + sources = sorted(path for path in source_dir.glob("*.go") if not path.name.endswith("_test.go")) + if len(sources) < 20: + raise RuntimeError(f"expected the complete MLow implementation, found only {len(sources)} files") + + shutil.rmtree(TARGET, ignore_errors=True) + TARGET.mkdir(parents=True, exist_ok=True) + for source_path in sources: + source = source_path.read_text(encoding="utf-8") + if "package mlow" not in source: + raise RuntimeError(f"unexpected package in {source_path}") + (TARGET / source_path.name).write_text(add_license_header(source), encoding="utf-8") + +codec = '''// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import "errors" + +const ( + MLowSampleRate = 16000 + MLowFrameSize = 960 +) + +var ( + ErrCodecClosed = errors.New("audio codec is closed") + ErrInvalidPCMFrame = errors.New("invalid PCM frame size") +) + +type Codec interface { + Encode(pcm []float32) ([]byte, error) + Decode(frame []byte) ([]float32, error) + FrameSize() int + SampleRate() int + Close() +} + +type CodecOptions struct { + Bitrate int + Complexity int + FEC bool +} + +var DefaultCodecOptions = CodecOptions{Bitrate: 6000, Complexity: 5, FEC: false} + +func NormalizeFrame(pcm []float32, samples int) []float32 { + if samples <= 0 { + return nil + } + if len(pcm) == samples { + return append([]float32(nil), pcm...) + } + normalized := make([]float32, samples) + copy(normalized, pcm) + return normalized +} +''' +(ROOT / "pkg/call/voip/media/codec.go").write_text(codec, encoding="utf-8") + +adapter = '''// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import ( + "fmt" + "sync" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/media/mlow" +) + +type mlowCodec struct { + mu sync.Mutex + enc *mlow.MlowEncoder + dec *mlow.MlowDecoder + closed bool +} + +func NewMLowCodec(opts CodecOptions) (Codec, error) { + _ = opts + return &mlowCodec{enc: mlow.NewMlowEncoder(), dec: mlow.NewMlowDecoder()}, nil +} + +func (c *mlowCodec) Encode(pcm []float32) ([]byte, error) { + if len(pcm) == 0 { + return nil, nil + } + if len(pcm) != MLowFrameSize { + return nil, fmt.Errorf("%w: got %d samples, want %d", ErrInvalidPCMFrame, len(pcm), MLowFrameSize) + } + c.mu.Lock() + defer c.mu.Unlock() + if c.closed || c.enc == nil { + return nil, ErrCodecClosed + } + input := append([]float32(nil), pcm...) + defer zeroFloat32(input) + encoded, err := c.enc.Encode(input) + if err != nil { + return nil, fmt.Errorf("encode MLow frame: %w", err) + } + return append([]byte(nil), encoded...), nil +} + +func (c *mlowCodec) Decode(frame []byte) ([]float32, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed || c.dec == nil { + return nil, ErrCodecClosed + } + input := append([]byte(nil), frame...) + defer zeroBytes(input) + decoded := c.dec.Decode(input) + return NormalizeFrame(decoded, MLowFrameSize), nil +} + +func (c *mlowCodec) FrameSize() int { return MLowFrameSize } +func (c *mlowCodec) SampleRate() int { return MLowSampleRate } + +func (c *mlowCodec) Close() { + if c == nil { + return + } + c.mu.Lock() + c.closed = true + c.enc = nil + c.dec = nil + c.mu.Unlock() +} + +func zeroFloat32(values []float32) { + for index := range values { + values[index] = 0 + } +} +''' +(ROOT / "pkg/call/voip/media/mlow_codec.go").write_text(adapter, encoding="utf-8") + +test = '''package media + +import ( + "errors" + "math" + "sync" + "testing" +) + +func TestMLowCodecAdapterRoundtrip(t *testing.T) { + codec, err := NewMLowCodec(DefaultCodecOptions) + if err != nil { + t.Fatal(err) + } + defer codec.Close() + frame := make([]float32, MLowFrameSize) + for i := range frame { + frame[i] = 0.25 * float32(math.Sin(2*math.Pi*440*float64(i)/MLowSampleRate)) + } + encoded, err := codec.Encode(frame) + if err != nil { + t.Fatal(err) + } + if len(encoded) == 0 { + t.Fatal("encoded frame is empty") + } + decoded, err := codec.Decode(encoded) + if err != nil { + t.Fatal(err) + } + if len(decoded) != MLowFrameSize { + t.Fatalf("decoded %d samples, want %d", len(decoded), MLowFrameSize) + } +} + +func TestMLowCodecPLCAndValidation(t *testing.T) { + codec, err := NewMLowCodec(DefaultCodecOptions) + if err != nil { + t.Fatal(err) + } + plc, err := codec.Decode(nil) + if err != nil { + t.Fatal(err) + } + if len(plc) != MLowFrameSize { + t.Fatalf("PLC returned %d samples", len(plc)) + } + if _, err = codec.Encode(make([]float32, 12)); !errors.Is(err, ErrInvalidPCMFrame) { + t.Fatalf("expected ErrInvalidPCMFrame, got %v", err) + } + codec.Close() + if _, err = codec.Decode(nil); !errors.Is(err, ErrCodecClosed) { + t.Fatalf("expected ErrCodecClosed, got %v", err) + } +} + +func TestMLowCodecSerializesConcurrentUse(t *testing.T) { + codec, err := NewMLowCodec(DefaultCodecOptions) + if err != nil { + t.Fatal(err) + } + defer codec.Close() + frame := make([]float32, MLowFrameSize) + var wg sync.WaitGroup + for worker := 0; worker < 4; worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for attempt := 0; attempt < 4; attempt++ { + encoded, encodeErr := codec.Encode(frame) + if encodeErr != nil { + t.Errorf("encode: %v", encodeErr) + return + } + if _, decodeErr := codec.Decode(encoded); decodeErr != nil { + t.Errorf("decode: %v", decodeErr) + return + } + } + }() + } + wg.Wait() +} +''' +(ROOT / "pkg/call/voip/media/mlow_codec_test.go").write_text(test, encoding="utf-8") + +(ROOT / "tools/port_mlow_codec.py").unlink() +(ROOT / ".github/workflows/port-mlow-codec.yml").unlink() From dafa3c41cc1633a1e5f5610ae17ad164a13b53d2 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:41:15 -0300 Subject: [PATCH 106/266] chore(call): run MLow codec port --- .github/workflows/port-mlow-codec.yml | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/port-mlow-codec.yml diff --git a/.github/workflows/port-mlow-codec.yml b/.github/workflows/port-mlow-codec.yml new file mode 100644 index 00000000..552567ee --- /dev/null +++ b/.github/workflows/port-mlow-codec.yml @@ -0,0 +1,49 @@ +name: Port MLow codec + +on: + pull_request: + paths: + - .github/workflows/port-mlow-codec.yml + - tools/port_mlow_codec.py + - pkg/call/** + +permissions: + contents: write + +jobs: + port: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: dev/astracalls-integration + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Port fixed MLow revision + run: python3 tools/port_mlow_codec.py + + - name: Format codec implementation + run: gofmt -w pkg/call/voip/media/codec.go pkg/call/voip/media/mlow_codec.go pkg/call/voip/media/mlow_codec_test.go pkg/call/voip/media/mlow/*.go + + - name: Test default call build + run: go test -race ./pkg/call/... + + - name: Test experimental Pion build + run: go test -race -tags=voip_pion ./pkg/call/... + + - name: Commit codec port + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "feat(call): port pure Go MLow audio codec" + git push origin HEAD:dev/astracalls-integration From 21d092f690c5560eddf80075c0687df1c5c1eb02 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:42:43 -0300 Subject: [PATCH 107/266] fix(call): include embedded MLow tables --- tools/port_mlow_codec.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/port_mlow_codec.py b/tools/port_mlow_codec.py index 00f1f769..e0f20419 100644 --- a/tools/port_mlow_codec.py +++ b/tools/port_mlow_codec.py @@ -31,8 +31,11 @@ def add_license_header(source: str) -> str: source_dir = checkout / "internal/voip/media/mlow" sources = sorted(path for path in source_dir.glob("*.go") if not path.name.endswith("_test.go")) + assets = sorted(source_dir.glob("*.bin")) if len(sources) < 20: raise RuntimeError(f"expected the complete MLow implementation, found only {len(sources)} files") + if len(assets) < 3: + raise RuntimeError(f"expected embedded MLow tables, found only {len(assets)} binary assets") shutil.rmtree(TARGET, ignore_errors=True) TARGET.mkdir(parents=True, exist_ok=True) @@ -41,6 +44,8 @@ def add_license_header(source: str) -> str: if "package mlow" not in source: raise RuntimeError(f"unexpected package in {source_path}") (TARGET / source_path.name).write_text(add_license_header(source), encoding="utf-8") + for asset_path in assets: + shutil.copy2(asset_path, TARGET / asset_path.name) codec = '''// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. package media From 9f0928ab11f92958855e193416117910fde36c28 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:43:41 +0000 Subject: [PATCH 108/266] feat(call): port pure Go MLow audio codec --- .github/workflows/port-mlow-codec.yml | 49 - pkg/call/voip/media/codec.go | 42 + pkg/call/voip/media/mlow/analysis.go | 862 +++++++++++++ pkg/call/voip/media/mlow/cc_seed.bin | Bin 0 -> 2150 bytes pkg/call/voip/media/mlow/cc_tables.go | 488 +++++++ pkg/call/voip/media/mlow/celp_enc.go | 1536 +++++++++++++++++++++++ pkg/call/voip/media/mlow/celpdec.go | 352 ++++++ pkg/call/voip/media/mlow/decoder.go | 188 +++ pkg/call/voip/media/mlow/encoder.go | 647 ++++++++++ pkg/call/voip/media/mlow/fft.go | 104 ++ pkg/call/voip/media/mlow/gains.go | 66 + pkg/call/voip/media/mlow/logging.go | 36 + pkg/call/voip/media/mlow/lpc.go | 585 +++++++++ pkg/call/voip/media/mlow/lsf.go | 156 +++ pkg/call/voip/media/mlow/lsf_quant.go | 502 ++++++++ pkg/call/voip/media/mlow/lsf_seed.bin | Bin 0 -> 30359 bytes pkg/call/voip/media/mlow/lsf_seed.go | 643 ++++++++++ pkg/call/voip/media/mlow/mem.go | 214 ++++ pkg/call/voip/media/mlow/noise.go | 528 ++++++++ pkg/call/voip/media/mlow/perc.go | 528 ++++++++ pkg/call/voip/media/mlow/pitch.go | 345 +++++ pkg/call/voip/media/mlow/pitch_enc.go | 680 ++++++++++ pkg/call/voip/media/mlow/pitch_seed.bin | Bin 0 -> 2362 bytes pkg/call/voip/media/mlow/pitch_seed.go | 256 ++++ pkg/call/voip/media/mlow/postfilter.go | 534 ++++++++ pkg/call/voip/media/mlow/pulse.go | 234 ++++ pkg/call/voip/media/mlow/rangecoder.go | 549 ++++++++ pkg/call/voip/media/mlow/red.go | 84 ++ pkg/call/voip/media/mlow/synth.go | 624 +++++++++ pkg/call/voip/media/mlow/toc.go | 76 ++ pkg/call/voip/media/mlow/vad.go | 393 ++++++ pkg/call/voip/media/mlow_codec.go | 74 ++ pkg/call/voip/media/mlow_codec_test.go | 83 ++ tools/port_mlow_codec.py | 259 ---- 34 files changed, 11409 insertions(+), 308 deletions(-) delete mode 100644 .github/workflows/port-mlow-codec.yml create mode 100644 pkg/call/voip/media/codec.go create mode 100644 pkg/call/voip/media/mlow/analysis.go create mode 100644 pkg/call/voip/media/mlow/cc_seed.bin create mode 100644 pkg/call/voip/media/mlow/cc_tables.go create mode 100644 pkg/call/voip/media/mlow/celp_enc.go create mode 100644 pkg/call/voip/media/mlow/celpdec.go create mode 100644 pkg/call/voip/media/mlow/decoder.go create mode 100644 pkg/call/voip/media/mlow/encoder.go create mode 100644 pkg/call/voip/media/mlow/fft.go create mode 100644 pkg/call/voip/media/mlow/gains.go create mode 100644 pkg/call/voip/media/mlow/logging.go create mode 100644 pkg/call/voip/media/mlow/lpc.go create mode 100644 pkg/call/voip/media/mlow/lsf.go create mode 100644 pkg/call/voip/media/mlow/lsf_quant.go create mode 100644 pkg/call/voip/media/mlow/lsf_seed.bin create mode 100644 pkg/call/voip/media/mlow/lsf_seed.go create mode 100644 pkg/call/voip/media/mlow/mem.go create mode 100644 pkg/call/voip/media/mlow/noise.go create mode 100644 pkg/call/voip/media/mlow/perc.go create mode 100644 pkg/call/voip/media/mlow/pitch.go create mode 100644 pkg/call/voip/media/mlow/pitch_enc.go create mode 100644 pkg/call/voip/media/mlow/pitch_seed.bin create mode 100644 pkg/call/voip/media/mlow/pitch_seed.go create mode 100644 pkg/call/voip/media/mlow/postfilter.go create mode 100644 pkg/call/voip/media/mlow/pulse.go create mode 100644 pkg/call/voip/media/mlow/rangecoder.go create mode 100644 pkg/call/voip/media/mlow/red.go create mode 100644 pkg/call/voip/media/mlow/synth.go create mode 100644 pkg/call/voip/media/mlow/toc.go create mode 100644 pkg/call/voip/media/mlow/vad.go create mode 100644 pkg/call/voip/media/mlow_codec.go create mode 100644 pkg/call/voip/media/mlow_codec_test.go delete mode 100644 tools/port_mlow_codec.py diff --git a/.github/workflows/port-mlow-codec.yml b/.github/workflows/port-mlow-codec.yml deleted file mode 100644 index 552567ee..00000000 --- a/.github/workflows/port-mlow-codec.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Port MLow codec - -on: - pull_request: - paths: - - .github/workflows/port-mlow-codec.yml - - tools/port_mlow_codec.py - - pkg/call/** - -permissions: - contents: write - -jobs: - port: - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout integration branch - uses: actions/checkout@v4 - with: - ref: dev/astracalls-integration - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Port fixed MLow revision - run: python3 tools/port_mlow_codec.py - - - name: Format codec implementation - run: gofmt -w pkg/call/voip/media/codec.go pkg/call/voip/media/mlow_codec.go pkg/call/voip/media/mlow_codec_test.go pkg/call/voip/media/mlow/*.go - - - name: Test default call build - run: go test -race ./pkg/call/... - - - name: Test experimental Pion build - run: go test -race -tags=voip_pion ./pkg/call/... - - - name: Commit codec port - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "feat(call): port pure Go MLow audio codec" - git push origin HEAD:dev/astracalls-integration diff --git a/pkg/call/voip/media/codec.go b/pkg/call/voip/media/codec.go new file mode 100644 index 00000000..ec33a0fc --- /dev/null +++ b/pkg/call/voip/media/codec.go @@ -0,0 +1,42 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import "errors" + +const ( + MLowSampleRate = 16000 + MLowFrameSize = 960 +) + +var ( + ErrCodecClosed = errors.New("audio codec is closed") + ErrInvalidPCMFrame = errors.New("invalid PCM frame size") +) + +type Codec interface { + Encode(pcm []float32) ([]byte, error) + Decode(frame []byte) ([]float32, error) + FrameSize() int + SampleRate() int + Close() +} + +type CodecOptions struct { + Bitrate int + Complexity int + FEC bool +} + +var DefaultCodecOptions = CodecOptions{Bitrate: 6000, Complexity: 5, FEC: false} + +func NormalizeFrame(pcm []float32, samples int) []float32 { + if samples <= 0 { + return nil + } + if len(pcm) == samples { + return append([]float32(nil), pcm...) + } + normalized := make([]float32, samples) + copy(normalized, pcm) + return normalized +} diff --git a/pkg/call/voip/media/mlow/analysis.go b/pkg/call/voip/media/mlow/analysis.go new file mode 100644 index 00000000..37729afc --- /dev/null +++ b/pkg/call/voip/media/mlow/analysis.go @@ -0,0 +1,862 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "math" + +// MLow encoder analysis — faithful port of analysis.rs (datasheets/mlow-encoder.md): +// PCM → SmplFrameParams. Per internal frame: LPC front-end (window → FFT-autocorr → +// A/NLSF) → bit-exact LSF quantizer → perceptual model + multi-stage pitch estimator +// + voicing classifier → CELP excitation encode → candidate selection (voiced LTP / +// unvoiced nrgres / silent), committed to a shadow synth for warm history, advancing +// the entropy predictor mirror. Validated end-to-end by the tone round-trip. + +const ( + smplLpcHistLen = 144 // C lpc_buf_mem + smplLpcPre = 96 + smplLsfSurv = 6 // lsf_surv at complexity 8 + smplWinnextWbLen = 32 + smplLsfRdwAdj float32 = 1.1952286 + + smplMainBitRate = 20000 + smplComplexity = 8 + + smplCelpLowRate = false + smplCelpPercRespLen = 32 + smplCelpFcbSubfrlen = 80 + smplCelpSubfrPerPacket = 12 + smplPercRLen = smplCelpPercRespLen + 1 // 33 + smplFcbTotSurv20msMax = 100 + smplEncHpFcornerHz float32 = 35.0 + + smplPercEmphPitch float32 = -0.82 + smplPitchPercRespLen = 17 + smplPitchLagMax = 320 + smplPitchLookaheadLen = 7 + smplVoicedNormGain float64 = 1.0 +) + +// SmplEncoderState is the cross-frame analysis history (only the LPC-analysis input +// history + the persistent sub-models persist; the decoder rebuilds synth per frame). +type SmplEncoderState struct { + hist []float64 + hpMA, hpAR [3]float32 + hpSet bool + hpState [4]float32 + celp *CelpEncoder + perc *PercModelState + percPrev []float32 + bitrate *BitrateController + lpcHist []float32 + prevLsfq []float32 + prevVoiced bool + vad *SmplVadState + vuv VuvMode + hpPitchHist []float32 + ltpBuf []float32 + pitchEst PitchEstState +} + +func unvoicedPitch() SmplPitchSynth { return SmplPitchSynth{} } + +type candidate struct { + ip SmplInternalParams + stage1 int32 + grid int32 + qsym [16]int32 + pulseVec []int32 + gainQ [4]int32 + pitch SmplPitchSynth + silent bool +} + +// celpFrameCtx is the borrowed CELP/perceptual state for one internal frame. +type celpFrameCtx struct { + celp *CelpEncoder + perc *PercModelState + percPrev *[]float32 + bitrate *BitrateController + hpN []float32 + intf int + spActProb float32 + codedAsActiveVoice bool + f2 [SmplFLen]float32 + voicingStrength float32 + vuv *VuvMode + hpPitchHist []float32 + ltpBuf *[]float32 + pitchEst *PitchEstState + percCorrs [][]float32 + blockLags [SmplSubfrCount][2]float32 +} + +type frontEndLsf struct { + a [SmplLPCOrder + 1]float32 + nlsf [SmplLPCOrder]float32 + prevLsfq []float32 + prevVoiced bool + intf int +} + +// smplAnalyzeFrameSt turns one 60 ms PCM frame (960 f32 @16 kHz, ~[-1,1]) into params. +func smplAnalyzeFrameSt(es *SmplEncoderState, pcm []float32) SmplFrameParams { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L857-L1046 + need := SmplIntfLen * 3 + if len(pcm) < need { + o := make([]float32, need) + copy(o, pcm) + pcm = o + } + synthT := LoadSmplSynthTables() + + pcmI16 := make([]int16, need) + for i := 0; i < need; i++ { + v := math.Round(float64(pcm[i] * 32768.0)) + if v > 32767 { + v = 32767 + } + if v < -32768 { + v = -32768 + } + pcmI16[i] = int16(v) + } + if es.vad == nil { + es.vad = NewSmplVadState() + } + vad := es.vad.ProcessPacket(pcmI16, SmplIntfLen) + spActProb := vad.VadResults + codedAsActiveVoice := vad.CodedAsActiveVoice + + if !es.hpSet { + es.hpMA, es.hpAR = SmplGetHpCoefs(smplEncHpFcornerHz) + es.hpSet = true + } + pcmIn := append([]float32(nil), pcm[:need]...) + hp := make([]float32, need) + SmplFiltArma2(pcmIn, need, es.hpMA, es.hpAR, &es.hpState, hp) + + x := make([]float64, SmplOrder+need) + if len(es.hist) >= SmplOrder { + copy(x[:SmplOrder], es.hist[len(es.hist)-SmplOrder:]) + } + for i := 0; i < need; i++ { + x[SmplOrder+i] = float64(hp[i]) * 32768.0 + } + + shadow := NewSmplFrameSynth() + var prevNlsf []float32 + var lstate SmplLsfState + + if es.celp == nil { + es.celp = NewCelpEncoder(smplCelpLowRate, smplCelpPercRespLen, smplCelpFcbSubfrlen, smplCelpSubfrPerPacket) + } + if es.perc == nil { + es.perc = NewPercModelState() + } + if es.bitrate == nil { + es.bitrate = NewBitrateController() + } + if len(es.percPrev) != smplPercRLen { + es.percPrev = make([]float32, smplPercRLen) + } + + resLead := SmplOrder + smplWinnextWbLen + xn := make([]float32, resLead+need) + if len(es.hist) >= resLead { + for i := 0; i < resLead; i++ { + xn[i] = float32(es.hist[len(es.hist)-resLead+i] / 32768.0) + } + } + copy(xn[resLead:resLead+need], hp[:need]) + + hpFull := make([]float32, smplLpcHistLen+need+smplWinnextWbLen) + if len(es.lpcHist) == smplLpcHistLen { + copy(hpFull[:smplLpcHistLen], es.lpcHist) + } + copy(hpFull[smplLpcHistLen:smplLpcHistLen+need], hp[:need]) + + hpPitchHist := make([]float32, smplPitchLagMax) + if len(es.hpPitchHist) == smplPitchLagMax { + copy(hpPitchHist, es.hpPitchHist) + } + es.hpPitchHist = append([]float32(nil), hp[need-smplPitchLagMax:need]...) + + if len(es.ltpBuf) != MaxLTPBufLen { + es.ltpBuf = make([]float32, MaxLTPBufLen) + } + + prevLsfq := append([]float32(nil), es.prevLsfq...) + prevVoiced := es.prevVoiced + + var internal [3]SmplInternalParams + for f := 0; f < 3; f++ { + base := SmplOrder + f*SmplIntfLen + win := x[base-SmplOrder : base+SmplIntfLen] + nbase := resLead + f*SmplIntfLen + winN := xn[nbase-resLead : nbase+SmplIntfLen] + + lpcStart := smplLpcHistLen - smplLpcPre + f*SmplIntfLen + var lpcbuf [SmplLPCBufLen]float32 + copy(lpcbuf[:], hpFull[lpcStart:lpcStart+SmplLPCBufLen]) + windowed := smplWindowLPC20(&lpcbuf, f < 2) + a, f2 := smplLPCAnalyzeWithF2(&windowed) + nlsf := smplA2NLSF16(a[:]) + + cs := celpFrameCtx{ + celp: es.celp, + perc: es.perc, + percPrev: &es.percPrev, + bitrate: es.bitrate, + hpN: hp, + intf: f, + spActProb: spActProb[f], + codedAsActiveVoice: codedAsActiveVoice, + f2: f2, + vuv: &es.vuv, + hpPitchHist: hpPitchHist, + ltpBuf: &es.ltpBuf, + pitchEst: &es.pitchEst, + } + var feA [SmplLPCOrder + 1]float32 + copy(feA[:], a[:]) + var feNlsf [SmplLPCOrder]float32 + copy(feNlsf[:], nlsf[:]) + fe := frontEndLsf{a: feA, nlsf: feNlsf, prevLsfq: prevLsfq, prevVoiced: prevVoiced, intf: f} + + ip, nlsfOut, voicedOut := smplAnalyzeInternal(synthT, shadow, &lstate, f, win, winN, prevNlsf, &fe, &cs) + prevNlsf = nlsfOut + prevLsfq = nlsfOut + prevVoiced = voicedOut + internal[f] = ip + if f == 2 { + es.pitchEst.ResetCond() + } + } + + es.hist = append([]float64(nil), x[len(x)-(SmplOrder+smplWinnextWbLen):]...) + es.lpcHist = append([]float32(nil), hp[need-smplLpcHistLen:need]...) + es.prevLsfq = prevLsfq + es.prevVoiced = prevVoiced + return SmplFrameParams{TOC: 0x50, Config: 0, Internal: internal} +} + +// quantize runs the bit-exact LSF quantizer + the C cond-coding condition. +func (fe *frontEndLsf) quantize(synthT *SmplSynthTables, voiced int, prevNlsf []float32) (int32, [16]int32, []float32, [17]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1065-L1105 + cond := (fe.prevVoiced == (voiced != 0)) && fe.intf > 0 + var res LsfQuantResult + if cond && len(fe.prevLsfq) == SmplLPCOrder { + res = LsfQuantCond(fe.a[:], fe.nlsf[:], fe.prevLsfq, voiced, 0, smplLsfRdwAdj, smplLsfSurv) + } else { + res = LsfQuant(fe.a[:], fe.nlsf[:], voiced, 0, smplLsfRdwAdj, smplLsfSurv) + } + grid := res.Qi[0] + var stage2 [16]int32 + copy(stage2[:], res.Qi[1:1+SmplLPCOrder]) + committed := SmplReconstructNLSF(synthT, voiced, 0, int(grid), &stage2, prevNlsf) + aVq := SmplNLSF2A(committed) + var predcoef [17]float32 + for i := 0; i < 17 && i < len(aVq); i++ { + predcoef[i] = aVq[i] + } + predcoef[0] = 1.0 + return grid, stage2, committed, predcoef +} + +func commitCandidate(synthT *SmplSynthTables, st *SmplFrameSynth, cand *candidate, prevNlsf []float32) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1108-L1151 + if cand.silent { + nlsf := SmplReconstructNLSF(synthT, 0, 0, int(cand.ip.Lsf.Grid), &cand.ip.Lsf.Stage2, prevNlsf) + pulseVec := make([]int32, SmplIntfLen) + var st2 [16]int32 = cand.ip.Lsf.Stage2 + SynthInternalFrame(synthT, st, 0, 0, int(cand.ip.Lsf.Grid), &st2, prevNlsf, pulseVec, &cand.gainQ, &cand.pitch) + return nlsf + } + var qsym [16]int32 = cand.qsym + _, nlsf := SynthInternalFrame(synthT, st, int(cand.stage1), 0, int(cand.grid), &qsym, prevNlsf, cand.pulseVec, &cand.gainQ, &cand.pitch) + return nlsf +} + +func smplUnvoicedCandidate(synthT *SmplSynthTables, _ *SmplFrameSynth, win []float64, winN []float32, prevNlsf []float32, fe *frontEndLsf, cs *celpFrameCtx) candidate { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1153-L1273 + frame := win[SmplOrder:] + r0 := smplAutocorr(frame, 0)[0] + if r0 <= 0.0 { + var flat [SmplSubfrCount][17]float32 + for sf := range flat { + flat[sf][0] = 1.0 + } + percCorrs := cs.percCorrs + runCelpSubframes(cs, &flat, make([]float32, SmplIntfLen), &[SmplSubfrCount][2]float32{}, percCorrs, SmplPercEmphUV, 0) + return smplSilentInternal(synthT) + } + + bgrid, bsym, brec, _ := fe.quantize(synthT, 0, prevNlsf) + predcoefs, resLpc, interpolIdx := smplLsfInterpolSearch(brec, fe.prevLsfq, winN) + + percCorrs := cs.percCorrs + celpOut := runCelpSubframes(cs, &predcoefs, resLpc, &[SmplSubfrCount][2]float32{}, percCorrs, SmplPercEmphUV, 0) + + pulseVec := make([]int32, SmplIntfLen) + var fcbgIdx [4]int32 + const main = 1 + for sf := 0; sf < SmplSubfrCount; sf++ { + out := &celpOut[sf] + for _, v := range out.Pulses[main] { + sign := int32(1) + 2*(int32(v)>>15) + pos := int32(v)*sign - 1 + if pos >= 0 && pos < int32(SmplSubfrLen) { + pulseVec[sf*SmplSubfrLen+int(pos)] += sign + } + } + fcbgIdx[sf] = int32(out.GainIdx[main]) + } + + var nrgres [4]float32 + for sf := 0; sf < 4; sf++ { + res := resLpc[sf*SmplSubfrLen : (sf+1)*SmplSubfrLen] + var e float32 + for _, v := range res { + e += v * v + } + nrgres[sf] = e / float32(SmplSubfrLen) + } + nq := QuantNrgRes4(&nrgres) + gm := nq.FrameQi + gd := nq.ShapeQi + gainQ := nq.DbqQ14 + + pp := smplBuildPulseParams(pulseVec) + gains := SmplGainParams{GainMain: gm, GainDelta: gd, NrgRes: [4]int32{-1, -1, -1, -1}} + for sf := 0; sf < 4; sf++ { + if pp.Subfr[sf] > 0 { + gains.NrgRes[sf] = fcbgIdx[sf] + } else { + gains.NrgRes[sf] = -1 + } + } + + return candidate{ + ip: SmplInternalParams{ + Lsf: SmplLsfParams{Stage1: 0, Grid: bgrid, Stage2: bsym, Extra: interpolIdx}, + Pulses: pp, + Gains: gains, + }, + stage1: 0, + grid: bgrid, + qsym: bsym, + pulseVec: pulseVec, + gainQ: gainQ, + pitch: unvoicedPitch(), + } +} + +func runCelpSubframes(cs *celpFrameCtx, predcoefs *[SmplSubfrCount][17]float32, resLpc []float32, blockLags *[SmplSubfrCount][2]float32, percCorrs [][]float32, emph [2]float32, voiced int32) []CelpSubframeOut { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1279-L1359 + percWght := percCorrsToWght(percCorrs, emph, smplCelpPercRespLen) + outs := make([]CelpSubframeOut, 0, SmplSubfrCount) + + wnrgs := make([]float32, SmplSubfrCount) + for sf := 0; sf < SmplSubfrCount; sf++ { + res := resLpc[sf*SmplSubfrLen : (sf+1)*SmplSubfrLen] + const scale = 32768.0 + var s float32 + for _, v := range res { + s += (v * scale) * (v * scale) + } + wnrgs[sf] = s + } + + enc := BitrateControllerInputs{ + InternalSampleRate: 16000, PayloadSizeMs: 60, FecBitRate: 0, MainBitRate: smplMainBitRate, + Complexity: smplComplexity, UseFecRateCompensation: 0, UseDtx: 0, SubFrameImportanceFactor: 1.0, + } + + for sf := 0; sf < SmplSubfrCount; sf++ { + wnrg := wnrgs[sf] + wnrgNext := wnrgs[sf] + if sf+1 < SmplSubfrCount { + wnrgNext = wnrgs[sf+1] + } + var nonflatness float32 = 2.0 + if voiced != 0 { + nonflatness = 0.0 + } + maxPulses, importance := cs.bitrate.control(&enc, 0, boolToInt(cs.codedAsActiveVoice), cs.spActProb, nonflatness, cs.voicingStrength, voiced, wnrg, wnrgNext, 0, 320, 80) + numsurv := make([]int16, smplMaxPulsesPerSf) + for i := range numsurv { + numsurv[i] = 1 + } + totSurv := int32(1000 * (smplFcbTotSurv20msMax * smplCelpFcbSubfrlen) / (20 * 16000)) + smplDistributeFcbSurv(numsurv, int32(maxPulses[1]), totSurv) + + lags := []float32{blockLags[sf][0], blockLags[sf][1], blockLags[sf][1]} + res := resLpc[sf*SmplSubfrLen : (sf+1)*SmplSubfrLen] + pc := predcoefs[sf] + out := cs.celp.EncodeSubframe(res, &pc, percWght[sf], lags, importance, maxPulses, numsurv) + outs = append(outs, out) + } + return outs +} + +// computePercCorrs computes the per-subframe perceptual autocorrelation (advances +// perc state EXACTLY ONCE per internal frame). +func computePercCorrs(cs *celpFrameCtx) [SmplSubfrCount][]float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1367-L1397 + const frameMs = 20 + const shorter = 32 + var corrs [SmplSubfrCount][]float32 + for sf := 1; sf < SmplSubfrCount; sf += 2 { + start := cs.intf*SmplIntfLen + (sf-1)*SmplSubfrLen + xlen := 2*SmplSubfrLen + shorter + xsubfr := make([]float32, xlen) + for i := 0; i < xlen; i++ { + idx := start + i + if idx < len(cs.hpN) { + xsubfr[i] = cs.hpN[idx] + } + } + isLast := int32(0) + if cs.intf == 2 && sf == SmplSubfrCount-1 { + isLast = 1 + } + r := SmplPercModel(cs.perc, xsubfr, xlen, frameMs, isLast, smplPercRLen) + even := make([]float32, smplPercRLen) + for i := 0; i < smplPercRLen; i++ { + var prev float32 + if i < len(*cs.percPrev) { + prev = (*cs.percPrev)[i] + } + even[i] = 0.5 * (r[i] + prev) + } + corrs[sf-1] = even + *cs.percPrev = append([]float32(nil), r...) + corrs[sf] = r + } + return corrs +} + +func percCorrsToWght(corrs [][]float32, emph [2]float32, respLen int) [][]float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1401-L1414 + idx := 0 + if smplCelpLowRate { + idx = 1 + } + out := make([][]float32, len(corrs)) + for i, c := range corrs { + out[i] = SmplPercAc2a(c, smplPercRLen, emph[idx], respLen, SmplPercReg) + } + return out +} + +func smplLsfInterpolSearch(brec, prevLsfq []float32, winN []float32) ([SmplSubfrCount][17]float32, []float32, int32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1420-L1447 + residualFor := func(idx int) ([SmplSubfrCount][17]float32, []float32, float32) { + pc4, _ := smplLPCInterpolIdx(brec, prevLsfq, idx, SmplNLSF2A) + var predcoefs [SmplSubfrCount][17]float32 + for sf := 0; sf < SmplSubfrCount; sf++ { + predcoefs[sf] = pc4[sf] + } + res := make([]float32, SmplIntfLen) + var sumRms float32 + for sf := 0; sf < SmplSubfrCount; sf++ { + r := smplAnalysisResidualSubfr(&predcoefs[sf], winN, sf) + var nrg float32 + for _, v := range r { + nrg += v * v + } + sumRms += float32(math.Sqrt(float64(nrg + 1e-30))) + copy(res[sf*SmplSubfrLen:(sf+1)*SmplSubfrLen], r[:]) + } + return predcoefs, res, sumRms + } + pc0, res0, rms0 := residualFor(0) + pc1, res1, rms1 := residualFor(1) + if rms1 < rms0*0.998 { + return pc1, res1, 1 + } + return pc0, res0, 0 +} + +func smplAnalysisResidualSubfr(aSyn *[17]float32, winN []float32, sf int) [SmplSubfrLen]float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1451-L1466 + var res [SmplSubfrLen]float32 + for n := 0; n < SmplSubfrLen; n++ { + idx := SmplOrder + sf*SmplSubfrLen + n + acc := winN[idx] + for j := 1; j <= SmplOrder; j++ { + acc += aSyn[j] * winN[idx-j] + } + res[n] = acc + } + return res +} + +func smplSilentInternal(synthT *SmplSynthTables) candidate { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1468-L1500 + var sym [16]int32 + for k := 0; k < 16; k++ { + sym[k] = int32(len(synthT.Valtables[0][0][0][k]) / 2) + } + gm, gd, _ := smplRateControlGains(0.0) + return candidate{ + ip: SmplInternalParams{ + Lsf: SmplLsfParams{Stage1: 0, Grid: 0, Stage2: sym, Extra: 0}, + Gains: SmplGainParams{GainMain: gm, GainDelta: gd, NrgRes: [4]int32{-1, -1, -1, -1}}, + }, + stage1: 0, + grid: 0, + qsym: sym, + pulseVec: make([]int32, SmplIntfLen), + pitch: unvoicedPitch(), + silent: true, + } +} + +func smplAutocorr(x []float64, order int) []float64 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1502-L1513 + n := len(x) + r := make([]float64, order+1) + for lag := 0; lag <= order; lag++ { + var s float64 + for i := lag; i < n; i++ { + s += x[i] * x[i-lag] + } + r[lag] = s + } + return r +} + +func smplBuildPulseParams(pulse []int32) SmplPulseParams { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1515-L1580 + const p3 = 4 + posPer := SmplIntfLen / p3 + var pp SmplPulseParams + for sf := 0; sf < p3; sf++ { + var s int32 + for n := sf * posPer; n < (sf+1)*posPer; n++ { + a := pulse[n] + if a < 0 { + a = -a + } + s += a + } + pp.Subfr[sf] = s + } + pp.Total = pp.Subfr[0] + pp.Subfr[1] + pp.Subfr[2] + pp.Subfr[3] + + var magRuns []int32 + var signs []int32 + for sf := 0; sf < p3; sf++ { + if pp.Subfr[sf] <= 0 { + continue + } + basePos := posPer * sf + runPos := int32(basePos) + first := true + for n := basePos; n < basePos+posPer; n++ { + if pulse[n] == 0 { + continue + } + magv := pulse[n] + mag := magv + if mag < 0 { + mag = -mag + } + var m int32 + if first { + m = int32(n) - int32(basePos) + } else { + m = int32(n) - runPos + } + magRuns = append(magRuns, m) + runPos = int32(n) + if mag > 1 { + for k := int32(0); k < mag-1; k++ { + magRuns = append(magRuns, 0) + } + } + if magv < 0 { + signs = append(signs, -1) + } else { + signs = append(signs, 1) + } + first = false + } + } + pp.MagRuns = magRuns + + numPos := len(signs) + var signSyms []SmplRawSym + p := 0 + for p < numPos { + nbits := numPos - p + if nbits > 15 { + nbits = 15 + } + var sym uint32 + for q := 0; q < nbits; q++ { + var bit uint32 + if signs[p+q] > 0 { + bit = 1 + } + sym |= bit << uint(nbits-1-q) + } + signSyms = append(signSyms, SmplRawSym{Sym: sym, Nbits: uint32(nbits)}) + p += nbits + } + pp.SignSyms = signSyms + return pp +} + +func smplRateControlGains(targetLinear float64) (int32, int32, int32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1583-L1605 + mem := LoadSmplMem() + cfgSel := uint32(2) + cb1 := int32(mem.I16(0xf35e0 + cfgSel*2)) + gainTabAddr := uint32(0xf35f0) + bestD := math.Inf(1) + var bgm, bgd, bgq int32 + for gm := int32(0); gm < 84; gm++ { + base7 := gm*cb1 - 0x154000 + for gd := int32(0); gd < 98; gd++ { + cbv := int32(mem.I16(gainTabAddr + uint32(4*gd)*2)) + gq := base7 + (cbv << 4) + d := math.Abs(SmplGainLin(gq) - targetLinear) + if d < bestD { + bestD = d + bgm, bgd, bgq = gm, gd, gq + } + } + } + return bgm, bgd, bgq +} + +// buildLtpBuf rolls the persistent perceptually-weighted speech buffer and writes +// this internal frame's weighted speech + lookahead into its tail. +func buildLtpBuf(cs *celpFrameCtx, percCorrs [][]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1625-L1690 + respPitch := percCorrsToWght(percCorrs, [2]float32{smplPercEmphPitch, smplPercEmphPitch}, smplPitchPercRespLen) + maxLen := MaxLTPBufLen + look := smplPitchLookaheadLen + framelen := SmplIntfLen + ltp := *cs.ltpBuf + keep := maxLen - framelen - look + copy(ltp[0:keep], ltp[framelen:framelen+keep]) + + frameStart := cs.intf*SmplIntfLen - smplWinnextWbLen + hist := smplPitchLagMax + sample := func(rel int) float32 { + idx := frameStart + rel + if idx >= 0 { + if idx < len(cs.hpN) { + return cs.hpN[idx] + } + return 0.0 + } + if len(cs.hpPitchHist) == hist { + k := idx + hist + if k >= 0 { + return cs.hpPitchHist[k] + } + } + return 0.0 + } + wOrigin := maxLen - SmplSubfrCount*SmplSubfrLen - look + for i := 0; i < SmplSubfrCount; i++ { + coef := respPitch[i] + for n := 0; n < SmplSubfrLen; n++ { + pos := i*SmplSubfrLen + n + res := sample(pos) + for j := 1; j < smplPitchPercRespLen; j++ { + res += coef[j] * sample(pos-j) + } + ltp[wOrigin+i*SmplSubfrLen+n] = res + } + } + coef := respPitch[SmplSubfrCount-1] + for n := 0; n < look; n++ { + pos := framelen + n + res := sample(pos) + for j := 1; j < smplPitchPercRespLen; j++ { + res += coef[j] * sample(pos-j) + } + ltp[maxLen-look+n] = res + } +} + +func smplAnalyzeInternal(synthT *SmplSynthTables, st *SmplFrameSynth, lstate *SmplLsfState, intf int, win []float64, winN []float32, prevNlsf []float32, fe *frontEndLsf, cs *celpFrameCtx) (SmplInternalParams, []float32, bool) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1697-L1771 + corrs := computePercCorrs(cs) + cs.percCorrs = corrs[:] + buildLtpBuf(cs, append([][]float32(nil), cs.percCorrs...)) + f2 := cs.f2 + ltpBuf := append([]float32(nil), (*cs.ltpBuf)...) + pr := SmplPitch(cs.pitchEst, ltpBuf, &f2, cs.codedAsActiveVoice) + lags8 := pr.Lags + lagSamples := pr.Lags[0] + vstr := SmplGetSignalMode(pr.Pitchcorr, lags8[:], pr.AvgLag, pr.HarmStrength, &f2, cs.spActProb, cs.vuv) + cs.voicingStrength = vstr + isVoicedDecision := vstr > 0.0 && cs.codedAsActiveVoice + if isVoicedDecision { + lstate.PrevLagSamples = lagSamples + } else { + lstate.PrevLagSamples = 0.0 + } + if !isVoicedDecision { + cs.pitchEst.ResetCond() + lags8 = [8]float32{} + } + + voicedLstate := *lstate + SmplAdvanceLsfState(&voicedLstate, intf, 1) + var vd *voicedDecision + if isVoicedDecision { + vd = smplVoicedDecisionForLag(pr.BlocksegIdx, &pr.Laginds, cs, &lags8) + } + + var chosen candidate + var chosenLstate *SmplLsfState + var isVoiced bool + if vd != nil { + chosen = smplVoicedCandidate(synthT, win, prevNlsf, fe, cs, vd) + chosenLstate = &voicedLstate + isVoiced = true + } else { + chosen = smplUnvoicedCandidate(synthT, st, win, winN, prevNlsf, fe, cs) + isVoiced = false + } + committedNlsf := commitCandidate(synthT, st, &chosen, prevNlsf) + if chosen.stage1 == 1 { + *lstate = *chosenLstate + smplReplayPitchState(lstate, 4, chosen.ip.Pulses.Subfr, &chosen.ip.Pitch) + } else { + SmplAdvanceLsfState(lstate, intf, chosen.stage1) + } + return chosen.ip, committedNlsf, isVoiced +} + +func smplReplayPitchState(st *SmplLsfState, p3 int32, subfrCounts [4]int32, pp *SmplPitchParams) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1776-L1794 + take := int(p3) + if take > 4 { + take = 4 + } + for sf := 0; sf < take; sf++ { + st.PrevGainIdx = pp.GainIdx[sf] + if subfrCounts[sf] > 0 { + st.PrevFiltIdx = pp.FiltIdx[sf] + } + } + tab := LoadPitchTables() + nblk, nidx := smplLagsPredictorAfter(tab, pp.BlocksegIdx, &pp.Laginds) + st.PrevLagblk = nblk + st.PrevLagidx = nidx +} + +type voicedDecision struct { + pp SmplPitchParams + pitch SmplPitchSynth +} + +func smplVoicedDecisionForLag(blocksegIdx int, laginds *[8]int32, cs *celpFrameCtx, lags8 *[8]float32) *voicedDecision { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1808-L1838 + var blockLags8 [8]float32 + for b := 0; b < 8; b++ { + v := float32(laginds[b])*0.5 + 32.0 + if v > 320.0 { + v = 320.0 + } + blockLags8[b] = v + } + *lags8 = blockLags8 + for sf := 0; sf < SmplSubfrCount; sf++ { + cs.blockLags[sf] = [2]float32{blockLags8[2*sf], blockLags8[2*sf+1]} + } + var meanLag float32 + for _, v := range blockLags8 { + meanLag += v + } + meanLag /= 8.0 + + pp := SmplPitchParams{GainIdx: [4]int32{5, 5, 5, 5}, BlocksegIdx: blocksegIdx, Laginds: *laginds} + pitch := SmplPitchSynth{Voiced: true, LagSubfr: [4]float64{float64(meanLag), float64(meanLag), float64(meanLag), float64(meanLag)}, NormGain: smplVoicedNormGain} + return &voicedDecision{pp: pp, pitch: pitch} +} + +func smplVoicedCandidate(synthT *SmplSynthTables, win []float64, prevNlsf []float32, fe *frontEndLsf, cs *celpFrameCtx, vd *voicedDecision) candidate { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1846-L1930 + winN := make([]float32, len(win)) + for i, v := range win { + winN[i] = float32(v / 32768.0) + } + gainQ := [4]int32{} + + bgrid, bsym, brec, _ := fe.quantize(synthT, 1, prevNlsf) + pc4, _ := smplLPCInterpol(brec, fe.prevLsfq, SmplNLSF2A) + var predcoefs [SmplSubfrCount][17]float32 + for sf := 0; sf < SmplSubfrCount; sf++ { + predcoefs[sf] = pc4[sf] + } + resLpc := make([]float32, SmplIntfLen) + for sf := 0; sf < SmplSubfrCount; sf++ { + r := smplAnalysisResidualSubfr(&predcoefs[sf], winN, sf) + copy(resLpc[sf*SmplSubfrLen:(sf+1)*SmplSubfrLen], r[:]) + } + + blockLags := cs.blockLags + percCorrs := cs.percCorrs + celpOut := runCelpSubframes(cs, &predcoefs, resLpc, &blockLags, percCorrs, SmplPercEmphV, 1) + + const main = 1 + pulseVec := make([]int32, SmplIntfLen) + var acbg, fcbg [4]int32 + for sf := 0; sf < SmplSubfrCount; sf++ { + out := &celpOut[sf] + for _, v := range out.Pulses[main] { + sign := int32(1) + 2*(int32(v)>>15) + pos := int32(v)*sign - 1 + if pos >= 0 && pos < int32(SmplSubfrLen) { + pulseVec[sf*SmplSubfrLen+int(pos)] += sign + } + } + ai := int32(out.AcbIdx[main]) + if ai < 0 { + ai = 0 + } + if ai > 15 { + ai = 15 + } + acbg[sf] = ai + fi := int32(out.GainIdx[main]) + if fi < 0 { + fi = 0 + } + fcbg[sf] = fi + } + ppPulses := smplBuildPulseParams(pulseVec) + subfr := ppPulses.Subfr + pp := vd.pp + pp.GainIdx = acbg + for sf := 0; sf < 4; sf++ { + if subfr[sf] > 0 { + pp.FiltIdx[sf] = fcbg[sf] + } else { + pp.FiltIdx[sf] = -1 + } + } + + return candidate{ + ip: SmplInternalParams{ + Lsf: SmplLsfParams{Stage1: 1, Grid: bgrid, Stage2: bsym, Extra: 0}, + Pulses: ppPulses, + HasPitch: true, + Pitch: pp, + }, + stage1: 1, + grid: bgrid, + qsym: bsym, + pulseVec: pulseVec, + gainQ: gainQ, + pitch: vd.pitch, + } +} diff --git a/pkg/call/voip/media/mlow/cc_seed.bin b/pkg/call/voip/media/mlow/cc_seed.bin new file mode 100644 index 0000000000000000000000000000000000000000..004e32a0f63e3c0f89a40df0d7e996b7a513329b GIT binary patch literal 2150 zcmV-s2$}bI+U-|&R8&_UepBDfyy=B91I|!J7>e}HWN0Dw6%b3Jiv_VHAT}@%R4g$r zR*Z?FWHk|UT*X9I4T1?vg7G)woHfJH)BL~~G`SyuK;k&De#D?5g( zfJe7tvKgM14EmpKy;hR!NDQ)oZoy(u87wx8SP`fWwieb5x&>K)AT(PpXA0dW!ji}F zVcQ`vOvB-5Y;FY|^EQB;c;W~Y7T<5_nI0hTsxLXdalXV`#AlJPe~21PViLat`Yiwp z!$dNJ&9nCiTYRt?$ZEgeo3iMGU{5E$HJgqQa9H#^2J?->6G$`*zE5HW=*mk7a^O%1 zcpMh<$Ehb3_W!0|vlgtG97`@==pkNcmha0;8H>U^g*FxpDnf*D7|c7QTPz+=B-7jy z#L;mpH|)LC1KLiliE^L9p(5}*cgfydG$xnl=o!8|zYkQc^WxG;cq|6}uEKm^B86$~ z7_jUIVEiuHmWdGX|4RwjA4Z$jHgHow&C=tarnr8^j`O3t0!QdC98! z6VyG&m&LgIi-!V=NP%FNe|&HVb1BhvPi8`g)rK0dw&e4@Qx7m>*Kb>1Yrm}o9H>8f zY+8ZEhKyyWuDjeQ0-H5=^COaN-=CHApfafA7WmjGyOOZnEi`OxdX3ZmGLZ71u)sgv zYF7MAa4@9tt823>oBom^4ivdp`vFG8@bl#CxvMzcNQ!(W*x_AzEKgEUylIuMhtU47 z5ihw5szC0A4WILs^dmQc?KRkLa8p2t&;!ZAhYq;de@tdr^F_YlajP?O&zIMCk7|KY z^`iRB)>t1~Dt`R4c}JpN13e)rGMP-FQmGU&LLw4~G`sNhD$sWL!yrdX26cRlJPyP> z2#<^H_0r(5`#RJ$6a-z2+&!nzqRlPO9m>sWl8MX0BfW)LheduY{)e9fSd zj}+o&jCJ*;m_8vz(avk<>P|qZ2v?s)b~L@OL*Pn16(uyBhHnB(eU9-A#DXq>)1Z?% zi~KbZcQ)&m<37{R!bVeq)OEPOD@BSbk{8^i_0r`LzEpk-ALIxIN$Bauhk`nJLElY` zfW29TQzk)=h?hVLdy)Mp&vVc9(<@N4LYMwxAf3m zmx+cMDha%o?FXIHMnYXU!NcCp2+dFN&nN+gV!f}f_(=hBiU3>iKrHq$rdu=ETIe(n zZ>jQS1%d5qwu-@R#=MYuk4)_;l|HkNK*~@xq_d_cicnCd>BLV(6_6gQgAH?0H%4ns zA5m@5?dS}Qn<^8dK_%cb^&W=RB_9wfc)4~O^;$R>B%&NqVZ)uK5=M;fq3I*^Zfs$3 z{{2HCPU0B44BxM57&(FqL${dXwSzh`+X9il*eAUrPdCi5Pct5e>ZJm9ksqCSpzre2 zE4uxtI;vb@M?494gJmN}EPXNG^yll1nn-LQcCYC)VY`MZe~dH>SFjh1r8e|ZPmVtA zJ7)zO&Z|TWXG4X{3Vrg>25E=X&SoljszU2infpi3n5QH^T=h#4{NAk#e0TOBsf(C6 zblnmyyQR+UYG>SMp`XtktEW8|1mJjvPHi1_x1$NSZ&cOu1u+|&txnUeQbrH&9Ikf{ zLgyGp;n~_>CMGO zeS@R%xD!|>+)dj&El+ts-Y|Amsp4iE_rPjGyv{?JKUTnSH5`T5`Vpf8vBh`?*^chj zH!35k-@_^L^QufMUvjRZqLEV}yBn24t?4R7SKKI*XkFF2RY4g)fg&*lGjw&Pk?S zsr`Dv+jnBnKOwNCT6keNp0UM{gW*zp$C?x(B-?R&>wNV^!dcW36H6wgG-*Zn>7W?O zM^-B0i5FDEc&0YF`>2f{?{it(9u2LC+QL7j+ zo8_L?=ElY+j~koX+I!8!GKEs9l1Y2p>Tmw-%d9nVVP1|F)c;8MHvISR|Hq_Bp2Vcd zm^8_gCVA2%PnzUOll zp&f{t9}wX-&BN2n+t<(6&p*`H-{0TQ$J5=}af&saMrE(Co4#P<2@n;tbWLhnM%K0+ c*}Hb_-nk<)ZT;#cG10-!4t&8s08ifSu_Nj&sQ>@~ literal 0 HcmV?d00001 diff --git a/pkg/call/voip/media/mlow/cc_tables.go b/pkg/call/voip/media/mlow/cc_tables.go new file mode 100644 index 00000000..3e5319c0 --- /dev/null +++ b/pkg/call/voip/media/mlow/cc_tables.go @@ -0,0 +1,488 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "bytes" + "compress/zlib" + _ "embed" + "io" + "math/bits" +) + +// Logical seed-built tables for the nrgres/gains (Group A/E), LTP gain (Group C), +// and pulse (Group B) decode — built from a small DCMF seed (cc_seed.bin) instead +// of read by absolute pointer off the old cc_blob heap window. Port of +// smpl_cc_tables.rs. CDFs are the integer dcmf_to_cmf expansion; the split/runlen +// pulse CDFs are computed from the SILK fixed-point model; the gain-reconstruction +// rodata is carried verbatim. (Group D pitch lag/contour still uses SmplMem.) + +//go:embed cc_seed.bin +var ccSeedBlob []byte + +const ( + ccMaxPulsesPerSf = 40 + ccRunlengthStep = 8 + ccNumRunlenCmfs = 20 // SMPL_MAX_SF_LEN(160)/RUNLENGTH_STEP + ccSplitNumTables = ccMaxPulsesPerSf*4 - 1 + ccFcbgOffsetSteps = 176 + ccFcbgOffsetBuckets = 4 + ccAcbgN = 16 + ccAcbgRows = ccAcbgN + 1 + ccFcbgVN = 34 + ccFcbgVDeltaN = 67 +) + +// --- SILK fixed-point primitives (cc-prefixed to avoid the vad.go set) --- + +func ccSmulbb(a, b int32) int32 { return int32(int16(a)) * int32(int16(b)) } + +func ccSmlawb(a, b, c int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L18-L21 + return int32(int64(a) + ((int64(b) * int64(int16(c))) >> 16)) +} + +func ccClzFrac(in int32) (int32, int32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L24-L30 + u := uint32(in) + lz := int32(bits.LeadingZeros32(u)) + fracQ7 := int32(bits.RotateLeft32(u, -int((24-lz)&31))) & 0x7f + return lz, fracQ7 +} + +func ccLin2log(inLin int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L33-L37 + lz, fracQ7 := ccClzFrac(inLin) + return ccSmlawb(fracQ7, fracQ7*(128-fracQ7), 179) + ((31 - lz) << 7) +} + +func ccLog2lin(inLogQ7 int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L39-L57 + if inLogQ7 < 0 { + return 0 + } + if inLogQ7 >= 3967 { + return 0x7fffffff + } + out := int32(1) << uint(inLogQ7>>7) + fracQ7 := inLogQ7 & 0x7f + inner := ccSmlawb(fracQ7, ccSmulbb(fracQ7, 128-fracQ7), -174) + if inLogQ7 < 2048 { + out += (out * inner) >> 7 + } else { + out += (out >> 7) * inner + } + return out +} + +func ccSigmQ15(inQ5 int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L59-L79 + slope := [6]int32{237, 153, 73, 30, 12, 7} + pos := [6]int32{16384, 23955, 28861, 31213, 32178, 32548} + neg := [6]int32{16384, 8812, 3906, 1554, 589, 219} + if inQ5 < 0 { + v := -inQ5 + if v >= 6*32 { + return 0 + } + ind := v >> 5 + return neg[ind] - ccSmulbb(slope[ind], v&0x1f) + } + if inQ5 >= 6*32 { + return 32767 + } + ind := inQ5 >> 5 + return pos[ind] + ccSmulbb(slope[ind], inQ5&0x1f) +} + +// --- pulse-coding table builders (all integer/deterministic) --- + +// pdfToCmf is smpl_pdf_to_CMF (maxval==-1 path): truncating-int normalize into a u16 CDF. +func pdfToCmf(pdf []int32) []uint16 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L83-L96 + n := int64(len(pdf)) + const maxval int64 = 32767 + var sump int64 + for _, x := range pdf { + sump += int64(x) + } + cmf := make([]uint16, len(pdf)+1) + for i := 0; i < len(pdf); i++ { + p := (int64(pdf[i])*(maxval-n))/sump + 1 + cmf[i+1] = uint16(int32(cmf[i]) + int32(p)) + } + return cmf +} + +const ( + ccLog2Exp1Q15 = 47274 + ccLog22piQ14 = 43442 + ccOneQ31 = int64(1) << 31 +) + +func ccStirling(n int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L101-L110 + if n == 0 { + return 0 + } + ret := ((n << 1) + 1) * (ccLin2log(n) << 7) + ret -= int32(ccLog2Exp1Q15) * n + ret += ccLog22piQ14 + return ret + ccLog2Exp1Q15/(12*n) +} + +func ccProbSplitFast(k, n int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L112-L123 + tmp := ccStirling(n) - ccStirling(k) - ccStirling(n-k) - n*(1<<15) + if tmp == 0 { + return 1 << 30 + } + ret := ccLog2lin((-tmp) >> 8) + return (1 << 30) / ret +} + +func ccCreateSplitCmfs() [][]uint16 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L125-L137 + out := make([][]uint16, 0, ccSplitNumTables) + for numPulses := int32(1); numPulses <= ccSplitNumTables; numPulses++ { + minSplit := numPulses - ccMaxPulsesPerSf*2 + if minSplit < 0 { + minSplit = 0 + } + maxSplit := numPulses - minSplit + p := make([]int32, 0, maxSplit-minSplit+1) + for k := minSplit; k <= maxSplit; k++ { + p = append(p, ccProbSplitFast(k, numPulses)) + } + out = append(out, pdfToCmf(p)) + } + return out +} + +type runlenCmfs struct { + maxSamples int32 + cmfs [][]uint16 +} + +func ccCreateRunlenTable(maxSamples int32) runlenCmfs { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L141-L185 + ms := maxSamples + cmfs := make([][]uint16, 0, ccMaxPulsesPerSf) + for nump := int32(1); nump <= ccMaxPulsesPerSf; nump++ { + plongerQ31 := ccOneQ31 + p := make([]int32, ms) + for nums := int32(1); nums <= ms; nums++ { + tmp := ccOneQ31 - (ccOneQ31 / int64(ms-nums+1)) + p1Q31 := tmp + for r := int32(0); r < nump-1; r++ { + p1Q31 = (p1Q31 * tmp) >> 31 + } + p1Q31 = ccOneQ31 - p1Q31 + if p1Q31 > 2147376274 { + p1Q31 = 2147376274 + } + var logOutQ7 int32 + if nump > ms { + logOutQ7 = ccLin2log((nump<<10)/ms) - 10*128 + } else { + logOutQ7 = -(ccLin2log((ms<<10)/nump) - 10*128) + } + const sigmBiasQ5 = 146 + const scaleMaxQ15 = 36000 + const scaleMinQ15 = 26000 + scaleFacQ15 := int32(scaleMaxQ15) - (((scaleMaxQ15 - scaleMinQ15) * ccSigmQ15((logOutQ7>>2)+sigmBiasQ5)) >> 15) + p1Q31 = ccOneQ31 - int64(ccLog2lin(((scaleFacQ15*(ccLin2log(int32(ccOneQ31-p1Q31))-31*128))>>15)+31*128)) + if p1Q31 > 2147376274 { + p1Q31 = 2147376274 + } + p[nums-1] = int32((plongerQ31 * p1Q31) >> 31) + plongerQ31 = (plongerQ31 * (ccOneQ31 - p1Q31)) >> 31 + } + cmfs = append(cmfs, pdfToCmf(p)) + } + return runlenCmfs{maxSamples: ms, cmfs: cmfs} +} + +func (r *runlenCmfs) MaxSamples() int32 { return r.maxSamples } +func (r *runlenCmfs) Cmf(c int32) []uint16 { return r.cmfs[c-1] } + +// --- seed parse + table build --- + +type ccSeed struct { + nrgresGain4Dcmf []byte + nrgresShape4Dcmf []byte + fcbgOffsetDcmf []byte + acbgainsHrDcmf []byte + fcbgainsVDcmf []byte + fcbgainsVDeltaDcmf []byte + acbgainsCbHrQ14 []int32 + gainReconBase uint32 + gainRecon []byte + nPulsesDcmfBgn []byte + nPulsesDcmfUv []byte + nPulsesDcmfV []byte +} + +// protoField holds one decoded protobuf field (wiretype 0 varint or 2 bytes). +type protoField struct { + wire int + varint uint64 + bytes []byte +} + +func parseProto(b []byte) map[int]protoField { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_tables_blob.rs#L26-L29 + out := make(map[int]protoField) + i := 0 + readVarint := func() (uint64, bool) { + var v uint64 + var shift uint + for i < len(b) { + c := b[i] + i++ + v |= uint64(c&0x7f) << shift + if c&0x80 == 0 { + return v, true + } + shift += 7 + } + return 0, false + } + for i < len(b) { + key, ok := readVarint() + if !ok { + break + } + field := int(key >> 3) + wire := int(key & 7) + switch wire { + case 0: + v, ok := readVarint() + if !ok { + return out + } + out[field] = protoField{wire: 0, varint: v} + case 2: + ln, ok := readVarint() + if !ok || i+int(ln) > len(b) { + return out + } + out[field] = protoField{wire: 2, bytes: b[i : i+int(ln)]} + i += int(ln) + default: + return out + } + } + return out +} + +// decodeZigzagVarints decodes a packed repeated sint32 field. +func decodeZigzagVarints(b []byte) []int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L217-L218 + var out []int32 + i := 0 + for i < len(b) { + var v uint64 + var shift uint + for i < len(b) { + c := b[i] + i++ + v |= uint64(c&0x7f) << shift + if c&0x80 == 0 { + break + } + shift += 7 + } + out = append(out, int32(int64(v>>1)^-int64(v&1))) + } + return out +} + +func loadCcSeed() *ccSeed { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L331-L337 + zr, err := zlib.NewReader(bytes.NewReader(ccSeedBlob)) + if err != nil { + panic("mlow: inflate cc seed: " + err.Error()) + } + raw, err := io.ReadAll(zr) + zr.Close() + if err != nil { + panic("mlow: read cc seed: " + err.Error()) + } + f := parseProto(raw) + return &ccSeed{ + nrgresGain4Dcmf: f[1].bytes, + nrgresShape4Dcmf: f[2].bytes, + fcbgOffsetDcmf: f[3].bytes, + acbgainsHrDcmf: f[4].bytes, + fcbgainsVDcmf: f[5].bytes, + fcbgainsVDeltaDcmf: f[6].bytes, + acbgainsCbHrQ14: decodeZigzagVarints(f[7].bytes), + gainReconBase: uint32(f[8].varint), + gainRecon: f[9].bytes, + nPulsesDcmfBgn: f[10].bytes, + nPulsesDcmfUv: f[11].bytes, + nPulsesDcmfV: f[12].bytes, + } +} + +// ccDcmf is the integer dcmf→cmf (reusing the CELP port), returning a u16 CDF. +func ccDcmf(dcmf []byte) []uint16 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L83-L96 + c := make([]uint16, len(dcmf)+1) + celpDcmfToCmf(dcmf, len(dcmf), c) + return c +} + +func ccDcmfChunks(b []byte, step int) [][]uint16 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L262-L266 + out := make([][]uint16, 0, len(b)/step) + for i := 0; i+step <= len(b); i += step { + out = append(out, ccDcmf(b[i:i+step])) + } + return out +} + +// CcTables is the runtime nrgres/gains/LTP/pulse table set. +type CcTables struct { + nrgresGain4 []uint16 + nrgresShape4 []uint16 + fcbgOffset [][]uint16 + acbgainsHr []uint16 + acbgainsLr []uint16 + fcbgainsV []uint16 + fcbgainsVDelta []uint16 + acbgainsCbHrQ14 []int16 + acbgainsCbLrQ14 []int16 + gainRecon []int16 + gainReconBase uint32 + nPulseCmfs [3][]uint16 + splitCmfs [][]uint16 + runlen []runlenCmfs +} + +func (s *ccSeed) build() *CcTables { + t := &CcTables{ + nrgresGain4: ccDcmf(s.nrgresGain4Dcmf), + nrgresShape4: ccDcmf(s.nrgresShape4Dcmf), + fcbgOffset: ccDcmfChunks(s.fcbgOffsetDcmf, ccFcbgOffsetSteps), + fcbgainsV: ccDcmf(s.fcbgainsVDcmf), + fcbgainsVDelta: ccDcmf(s.fcbgainsVDeltaDcmf), + gainReconBase: s.gainReconBase, + } + // acbgains HR rows (17×17, flattened), then the LR variant from the const DCMF. + for i := 0; i+ccAcbgN <= len(s.acbgainsHrDcmf); i += ccAcbgN { + t.acbgainsHr = append(t.acbgainsHr, ccDcmf(s.acbgainsHrDcmf[i:i+ccAcbgN])...) + } + for i := 0; i+ccAcbgN <= len(celpAcbgainsDcmfLR); i += ccAcbgN { + t.acbgainsLr = append(t.acbgainsLr, ccDcmf(celpAcbgainsDcmfLR[i:i+ccAcbgN])...) + } + for _, x := range s.acbgainsCbHrQ14 { + t.acbgainsCbHrQ14 = append(t.acbgainsCbHrQ14, int16(x)) + } + t.acbgainsCbLrQ14 = cbAcbgainsLRQ14[:] + for i := 0; i+1 < len(s.gainRecon); i += 2 { + t.gainRecon = append(t.gainRecon, int16(uint16(s.gainRecon[i])|uint16(s.gainRecon[i+1])<<8)) + } + t.nPulseCmfs = [3][]uint16{ccDcmf(s.nPulsesDcmfBgn), ccDcmf(s.nPulsesDcmfUv), ccDcmf(s.nPulsesDcmfV)} + t.splitCmfs = ccCreateSplitCmfs() + for oct := int32(1); oct <= ccNumRunlenCmfs; oct++ { + t.runlen = append(t.runlen, ccCreateRunlenTable(oct*ccRunlengthStep)) + } + return t +} + +var ccTablesInst *CcTables + +// LoadCcTables expands the embedded cc seed ROM into the nrgres/gains/LTP/pulse tables once. +func LoadCcTables() *CcTables { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L331-L337 + if ccTablesInst == nil { + ccTablesInst = loadCcSeed().build() + } + return ccTablesInst +} + +// --- accessors (logical-index API matching smpl_cc_tables.rs) --- + +func (t *CcTables) NrgresGain4() []uint16 { return t.nrgresGain4 } +func (t *CcTables) NrgresShape4() []uint16 { return t.nrgresShape4 } + +func (t *CcTables) FcbgOffset(tableIx, bucket, minOffset int) []uint16 { + row := t.fcbgOffset[tableIx*ccFcbgOffsetBuckets+bucket] + return row[minOffset : minOffset+92] +} + +func (t *CcTables) AcbgainRow(prev int32) []uint16 { + base := int(prev+1) * (ccAcbgN + 1) + return t.acbgainsHr[base : base+ccAcbgN+1] +} + +func (t *CcTables) AcbgainRowLr(prev int32) []uint16 { + base := int(prev+1) * (ccAcbgN + 1) + return t.acbgainsLr[base : base+ccAcbgN+1] +} + +func (t *CcTables) AcbgainWeights(gi int32) (int32, int32) { + i := int(gi) * 2 + return int32(t.acbgainsCbHrQ14[i]), int32(t.acbgainsCbHrQ14[i+1]) +} + +func (t *CcTables) AcbgainWeightsLr(gi int32) (int32, int32) { + i := int(gi) * 2 + return int32(t.acbgainsCbLrQ14[i]), int32(t.acbgainsCbLrQ14[i+1]) +} + +func (t *CcTables) FcbgainV() []uint16 { return t.fcbgainsV } + +func (t *CcTables) FcbgainVDelta(prevFilt int32) []uint16 { + start := int(ccFcbgVN) - 1 - int(prevFilt) + return t.fcbgainsVDelta[start : start+35] +} + +func (t *CcTables) gainReconAt(addr uint32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L412-L421 + off := int(addr - t.gainReconBase) + if off >= 0 && off%2 == 0 && off/2 < len(t.gainRecon) { + return int32(t.gainRecon[off/2]) + } + return 0 +} + +func (t *CcTables) NrgStep(cfg int32) int32 { + return t.gainReconAt(t.gainReconBase + uint32(cfg)*2) +} + +func (t *CcTables) GainRecon(p4 bool, idx int32) int32 { + base := uint32(0xf3970) + if p4 { + base = 0xf35f0 + } + return t.gainReconAt(base + uint32(idx)*2) +} + +func (t *CcTables) NPulseCount(idx int32) []uint16 { return t.nPulseCmfs[idx] } + +func (t *CcTables) SplitCmf(total int32) []uint16 { + i := int(total - 1) + if i < 0 || i >= len(t.splitCmfs) { + return nil + } + return t.splitCmfs[i] +} + +func (t *CcTables) Runlen(oct int32) *runlenCmfs { return &t.runlen[oct-1] } + +// cdfWindow returns the n-entry CDF window base[start:start+n], zero-filling any +// out-of-range entries — the seed-table equivalent of the old mem.CDFAt zero-fill +// (RangeDecoder.decode_cdf_window in the reference). +// +// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/rangecoder.rs#L228-L245 +func cdfWindow(base []uint16, start, n int) []uint16 { + w := make([]uint16, n) + for i := 0; i < n; i++ { + if j := start + i; j >= 0 && j < len(base) { + w[i] = base[j] + } + } + return w +} diff --git a/pkg/call/voip/media/mlow/celp_enc.go b/pkg/call/voip/media/mlow/celp_enc.go new file mode 100644 index 00000000..f380eed3 --- /dev/null +++ b/pkg/call/voip/media/mlow/celp_enc.go @@ -0,0 +1,1536 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "math" + "sync" +) + +// MLow encoder-side CELP excitation — faithful port of smpl_celp.rs (Meta's +// smpl_celp_enc.c). Per subframe: build the perceptually-weighted impulse response, +// run the ACB/LTP search (voiced) and the FCB pulse search (greedy or delayed- +// decision beam), quantize the gains, and return the chosen pulses + indices + the +// reconstructed LPC excitation. The encode-side counterpart of mlow/synth's CELP. +// +// Datasheet: datasheets/mlow-celp.md. Reuses cbAcbgains{HR,LR}Q14, acbgN/acbgM, +// SmplLPCOrder, and the perc constants. No isolated byte-exact vector; validated by +// the encode_*_runs smoke tests and the end-to-end encoder tone round-trip. + +// celpMaxPitchLag, fcbgVN, uvGainIdxLen, acbgN/acbgM, celpInterpolKernel, +// cbAcbgains{HR,LR}Q14 are defined in celpdec.go and reused here. +const ( + celpLtpInterpolDelay = 8 + celpLagSubfrlen = 40 + celpMaxpitchLen = 320 + + celpGAcbRdMu float32 = 0.014999999664723873 + fcbgVDeltaN = 67 + + vGainMinDb float32 = -100.0 + vGainMaxDb float32 = 0.0 + vGainStepDb float32 = 3.0 + uvGainMinDb float32 = -90.0 + uvGainMaxDb float32 = 0.0 + uvGainStepDb float32 = 1.0 + + rateAcbScale float32 = 0.9 + pitchSharpeningCoef float32 = 0.9881 + fcbSrvMax = 4 + celpMaxNumsurv = 8 + nGainSteps = 2 +) + +var celpAcbgainsDcmfLR = [(acbgN + 1) * acbgN]uint8{ + 103, 70, 48, 3, 122, 135, 47, 192, 2, 255, 99, 96, 186, 194, 4, 28, + 161, 90, 76, 3, 181, 60, 37, 219, 2, 132, 81, 146, 255, 43, 3, 36, + 114, 222, 55, 6, 203, 34, 42, 154, 6, 255, 33, 209, 225, 78, 6, 45, + 198, 161, 110, 8, 239, 26, 35, 162, 4, 117, 42, 214, 255, 33, 6, 72, + 55, 255, 124, 55, 124, 55, 55, 55, 55, 78, 55, 215, 111, 55, 55, 167, + 154, 136, 77, 4, 220, 33, 38, 166, 2, 144, 50, 196, 255, 43, 4, 41, + 56, 21, 19, 3, 48, 255, 38, 220, 2, 225, 107, 31, 122, 227, 2, 11, + 63, 38, 23, 4, 77, 85, 58, 190, 4, 255, 53, 53, 145, 138, 4, 14, + 95, 47, 33, 2, 110, 146, 53, 255, 2, 219, 79, 73, 198, 122, 2, 15, + 84, 255, 84, 84, 147, 84, 84, 84, 84, 120, 84, 120, 84, 84, 84, 84, + 73, 58, 25, 1, 95, 99, 52, 175, 1, 255, 48, 69, 151, 184, 1, 15, + 105, 32, 43, 2, 84, 225, 34, 255, 2, 156, 129, 49, 189, 124, 3, 19, + 152, 230, 89, 6, 253, 28, 40, 153, 2, 195, 31, 255, 249, 58, 5, 61, + 138, 84, 54, 3, 173, 96, 45, 247, 2, 176, 83, 128, 255, 69, 2, 26, + 22, 17, 8, 1, 23, 106, 26, 88, 1, 182, 37, 18, 50, 255, 1, 6, + 218, 174, 228, 65, 186, 65, 65, 92, 65, 65, 65, 255, 174, 65, 65, 174, + 117, 255, 101, 16, 180, 20, 33, 94, 10, 131, 20, 222, 143, 38, 15, 105, +} + +var celpAcbgainsDcmfHR = [(acbgN + 1) * acbgN]uint8{ + 254, 105, 212, 26, 110, 255, 202, 93, 152, 121, 110, 43, 150, 20, 81, 176, + 255, 28, 100, 5, 26, 184, 61, 29, 36, 26, 28, 9, 61, 4, 27, 116, + 121, 255, 161, 39, 195, 215, 191, 75, 186, 178, 119, 82, 68, 41, 43, 56, + 188, 65, 243, 15, 74, 255, 205, 79, 123, 84, 95, 26, 139, 13, 67, 154, + 81, 219, 173, 70, 219, 165, 234, 102, 231, 255, 191, 119, 87, 60, 62, 59, + 106, 255, 182, 49, 242, 196, 233, 95, 247, 228, 152, 96, 81, 45, 54, 61, + 236, 55, 178, 10, 56, 255, 131, 54, 85, 58, 59, 18, 93, 9, 43, 133, + 123, 95, 224, 24, 113, 202, 255, 105, 186, 134, 135, 38, 141, 18, 82, 111, + 126, 97, 204, 34, 126, 186, 255, 141, 210, 147, 149, 46, 165, 22, 113, 122, + 96, 156, 185, 42, 188, 178, 255, 116, 248, 199, 157, 66, 109, 29, 69, 75, + 102, 207, 194, 57, 224, 193, 255, 107, 253, 242, 180, 95, 97, 44, 60, 64, + 105, 119, 202, 39, 140, 189, 255, 110, 207, 173, 165, 54, 119, 24, 75, 85, + 74, 255, 142, 59, 214, 150, 182, 76, 194, 215, 138, 122, 61, 56, 41, 45, + 200, 53, 255, 17, 66, 238, 222, 109, 129, 78, 101, 21, 227, 11, 110, 243, + 74, 255, 128, 50, 187, 149, 154, 63, 165, 184, 115, 101, 52, 47, 37, 34, + 159, 66, 232, 26, 86, 196, 255, 146, 171, 113, 134, 31, 245, 16, 145, 190, + 255, 29, 182, 7, 33, 235, 115, 55, 59, 37, 47, 11, 139, 6, 60, 234, +} + +var celpFcbgVDcmf = [fcbgVN]uint8{ + 107, 12, 17, 25, 31, 41, 52, 65, 83, 103, 122, 146, 169, 191, 210, 227, + 240, 249, 255, 253, 246, 229, 200, 161, 120, 82, 51, 29, 14, 6, 2, 2, + 2, 2, +} + +var celpFcbgVDeltaDcmf = [fcbgVDeltaN]uint8{ + 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 4, 6, 8, 10, 12, 12, + 12, 13, 14, 14, 14, 13, 12, 11, 10, 9, 8, 9, 15, 33, 65, 119, + 196, 255, 220, 144, 90, 57, 36, 23, 17, 14, 12, 12, 12, 13, 12, 12, + 12, 12, 12, 11, 11, 10, 9, 7, 6, 4, 3, 2, 1, 1, 1, 1, + 1, 1, 1, +} + +// --- leaf math helpers ------------------------------------------------------ + +func celpDotProd(a, b []float32, l int) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L202-L208 + var r float32 + for i := 0; i < l; i++ { + r += a[i] * b[i] + } + return r +} + +func celpNrg(x []float32, n int) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L211-L217 + var s float32 + for k := 0; k < n; k++ { + s += x[k] * x[k] + } + return s +} + +func celpReverse(x []float32, l int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L220-L224 + for i := 0; i < l/2; i++ { + x[i], x[l-i-1] = x[l-i-1], x[i] + } +} + +func celpSubVec(y, z, x []float32, l int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L243-L247 + for i := 0; i < l; i++ { + x[i] = y[i] - z[i] + } +} + +func celpAddVecInplace(y, x []float32, l int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L251-L255 + for i := 0; i < l; i++ { + x[i] += y[i] + } +} + +func celpScaleVecInplace(x []float32, l int, g float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L266-L270 + for i := 0; i < l; i++ { + x[i] *= g + } +} + +func celpScaleVec(x, y []float32, l int, g float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L273-L277 + for i := 0; i < l; i++ { + y[i] = x[i] * g + } +} + +func celpAddScaleVecInplace(x, y []float32, l int, g float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L281-L285 + for i := 0; i < l; i++ { + y[i] += g * x[i] + } +} + +func celpAddScaleVec(x0, x1, y []float32, l int, g float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L289-L293 + for i := 0; i < l; i++ { + y[i] = x0[i] + g*x1[i] + } +} + +func celpMulVecInplace(x, y []float32, l int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L296-L300 + for i := 0; i < l; i++ { + y[i] *= x[i] + } +} + +func celpQ(num, den []float32, l int, q []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L303-L307 + for i := 0; i < l; i++ { + q[i] = (num[i] * num[i]) / den[i] + } +} + +// celpMultSymtoepl2: symmetric Toeplitz multiply. c carries the trailing zero at +// 2*lResp-1; x must be readable up to n+lResp (zero padded). +func celpMultSymtoepl2(c []float32, lResp int, x, y []float32, n int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L312-L334 + length := lResp + nn := 0 + for nn < lResp-1 { + y[nn] = celpDotProd(c[lResp-1-nn:], x[0:], length) + length++ + nn++ + } + length = 2 * lResp + for nn < n-lResp { + y[nn] = celpDotProd(c[0:], x[nn+1-lResp:], length) + nn++ + } + for nn < n { + length-- + y[nn] = celpDotProd(c[0:], x[nn+1-lResp:], length) + nn++ + } +} + +// celpFiltAr16: 16th-order AR filter; the 16-sample state sits in y[yBase-16 .. yBase]. +func celpFiltAr16(x []float32, n int, coef []float32, yBase int, y []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L338-L347 + for nn := 0; nn < n; nn++ { + res := x[nn] + for i := 0; i < 16; i++ { + res -= coef[16-i] * y[yBase+nn-16+i] + } + y[yBase+nn] = res + } +} + +// celpFiltMa: MA filter; (coefLen-1) history samples sit before x[xBase]. x != y. +func celpFiltMa(x []float32, xBase, n int, coef []float32, coefLen int, y []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L350-L370 + var i int + if coef[0] == 1.0 { + for k := 0; k < n; k++ { + y[k] = x[xBase+k] + coef[1]*x[xBase+k-1] + } + i = 2 + } else { + for k := 0; k < n; k++ { + y[k] = coef[0] * x[xBase+k] + } + i = 1 + } + for i < coefLen { + for k := 0; k < n; k++ { + y[k] += coef[i] * x[xBase+k-i] + } + i++ + } +} + +// celpFiltMa9: 9th-order MA; the 9-sample history sits before x[xBase]. +func celpFiltMa9(x []float32, xBase, n int, coef []float32, _ int, y []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L373-L388 + for nn := 0; nn < n; nn++ { + var res float32 + for i := 0; i < 10; i++ { + res += coef[i] * x[xBase+nn-i] + } + y[nn] = res + } +} + +// celpDcmfToCmf: INTEGER, bit-exact dcmf→cmf (truncating-int normalize). +func celpDcmfToCmf(dcmf []uint8, dcmfLen int, cmf []uint16) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L403-L419 + var sum int32 + for n := 0; n < dcmfLen; n++ { + tmp := int32(dcmf[n]) + 1 + tmp *= tmp + if tmp > 65535 { + tmp = 65535 + } + cmf[n+1] = uint16(tmp) + sum += tmp + } + cmf[0] = 0 + for n := 1; n < dcmfLen+1; n++ { + cmf[n] = cmf[n-1] + uint16((int32(cmf[n])*(32767-int32(dcmfLen)))/sum) + 1 + } +} + +func celpCmfToBits(cmf []uint16, cmfLen int, bits []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L421-L425 + for i := 0; i < cmfLen-1; i++ { + bits[i] = -float32(math.Log2(float64(float32(cmf[i+1]-cmf[i]) / float32(cmf[cmfLen-1])))) + } +} + +func celpGetMaxi(x []float32, xLen int) int { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L430-L440 + i := 0 + mx := x[0] + for n := 1; n < xLen; n++ { + if x[n] > mx { + mx = x[n] + i = n + } + } + return i +} + +func celpGetMaxiK(x []float32, idx []int32, xLen, k int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L445-L462 + taken := make([]bool, xLen) + for kk := 0; kk < k; kk++ { + var best float32 = -math.MaxFloat32 + bi := 0 + found := false + for n := 0; n < xLen; n++ { + if !taken[n] && (!found || x[n] > best) { + best = x[n] + bi = n + found = true + } + } + taken[bi] = true + idx[kk] = int32(bi) + } +} + +// --- CELP tables (smpl_create_celp_tables) --------------------------------- + +type celpTables struct { + acbgInvProbLR [(acbgN + 1) * acbgN]float32 + acbgInvProbHR [(acbgN + 1) * acbgN]float32 + fcbgainsV [fcbgVN]float32 + fcbgainsUV [uvGainIdxLen + 1]float32 + fcbgVInvProb [fcbgVN]float32 + fcbgVDeltaInvProb [fcbgVDeltaN]float32 +} + +var ( + celpTablesOnce sync.Once + celpTablesInst *celpTables +) + +func getCelpTables() *celpTables { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L483-L485 + celpTablesOnce.Do(func() { celpTablesInst = buildCelpTables() }) + return celpTablesInst +} + +func buildCelpTables() *celpTables { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L487-L566 + t := &celpTables{} + acbCmfLR := make([]uint16, (acbgN+1)*(acbgN+1)) + acbCmfHR := make([]uint16, (acbgN+1)*(acbgN+1)) + for i := 0; i < acbgN+1; i++ { + celpDcmfToCmf(celpAcbgainsDcmfLR[i*acbgN:], acbgN, acbCmfLR[i*(acbgN+1):]) + celpDcmfToCmf(celpAcbgainsDcmfHR[i*acbgN:], acbgN, acbCmfHR[i*(acbgN+1):]) + } + for i := 0; i < acbgN+1; i++ { + celpCmfToBits(acbCmfLR[i*(acbgN+1):], acbgN+1, t.acbgInvProbLR[i*acbgN:]) + celpCmfToBits(acbCmfHR[i*(acbgN+1):], acbgN+1, t.acbgInvProbHR[i*acbgN:]) + for j := 0; j < acbgN; j++ { + t.acbgInvProbLR[i*acbgN+j] = float32(math.Pow(2.0, float64(t.acbgInvProbLR[i*acbgN+j]*celpGAcbRdMu))) + t.acbgInvProbHR[i*acbgN+j] = float32(math.Pow(2.0, float64(t.acbgInvProbHR[i*acbgN+j]*celpGAcbRdMu))) + } + } + fcbgVCmf := make([]uint16, fcbgVN+1) + fcbgVDeltaCmf := make([]uint16, fcbgVDeltaN+1) + celpDcmfToCmf(celpFcbgVDcmf[:], fcbgVN, fcbgVCmf) + celpDcmfToCmf(celpFcbgVDeltaDcmf[:], fcbgVDeltaN, fcbgVDeltaCmf) + celpCmfToBits(fcbgVCmf, fcbgVN+1, t.fcbgVInvProb[:]) + for i := 0; i < fcbgVN; i++ { + t.fcbgVInvProb[i] = float32(math.Pow(2.0, float64(t.fcbgVInvProb[i]*celpGAcbRdMu))) + } + celpCmfToBits(fcbgVDeltaCmf, fcbgVDeltaN+1, t.fcbgVDeltaInvProb[:]) + for i := 0; i < fcbgVDeltaN; i++ { + t.fcbgVDeltaInvProb[i] = float32(math.Pow(2.0, float64(t.fcbgVDeltaInvProb[i]*celpGAcbRdMu))) + } + for ix := 0; ix < fcbgVN; ix++ { + db := float32(ix)*vGainStepDb + vGainMinDb + t.fcbgainsV[ix] = float32(math.Pow(10.0, float64(0.05*db))) + } + for ix := 0; ix <= uvGainIdxLen; ix++ { + db := float32(ix)*uvGainStepDb + uvGainMinDb + t.fcbgainsUV[ix] = float32(math.Pow(10.0, float64(0.05*db))) + } + return t +} + +// --- LTP / ACB synthesis ---------------------------------------------------- + +func celpAcbDequant(lowRate bool, acbIdx int32, acbG *[acbgM]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L573-L583 + cb := &cbAcbgainsHRQ14 + if lowRate { + cb = &cbAcbgainsLRQ14 + } + scQ14 := 1.0 / float32(int32(1)<<14) + for m := 0; m < acbgM; m++ { + acbG[m] = float32(cb[int(acbIdx)*acbgM+m]) * scQ14 + } +} + +func celpAcbSynthesize(fcbSubfrlen int, acbBasis []float32, acbG *[acbgM]float32, acb []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L587-L595 + celpScaleVec(acbBasis, acb, fcbSubfrlen, acbG[0]) + celpAddScaleVecInplace(acbBasis[fcbSubfrlen:], acb, fcbSubfrlen, acbG[1]) +} + +func celpPitchSharp(x []float32, lag, l int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L598-L602 + for i := lag; i < l; i++ { + x[i] += x[i-lag] * pitchSharpeningCoef + } +} + +// celpSynLtpBasis builds the LTP basis per 40-sample sub-block and extends state in place. +func celpSynLtpBasis(lags []float32, nLags int, state []float32, stateLen int, acbBasis []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L607-L678 + p := stateLen - nLags*celpLagSubfrlen + for subfr := 0; subfr < nLags; subfr++ { + iLag := int32(math.Floor(float64(lags[subfr]))) + if float32(iLag) == lags[subfr] { + il := int(iLag) + for i := 0; i < celpLagSubfrlen; i++ { + state[p+i] = state[(p+i)-il] + } + for i := 0; i < celpLagSubfrlen; i++ { + acbBasis[subfr*celpLagSubfrlen+i] = state[p+i] + } + for i := 0; i < celpLagSubfrlen; i++ { + a := state[(p+i)-il-1] + b := state[(p+i)-il+1] + acbBasis[(nLags+subfr)*celpLagSubfrlen+i] = a + b + } + } else { + il := int(iLag) + baseFirst := p + (-1 - il - celpLtpInterpolDelay) + first := celpDotProd(state[baseFirst:], celpInterpolKernel[:], 2*celpLtpInterpolDelay) + srcBase := p + (-il - celpLtpInterpolDelay) + for nn := 0; nn < celpLagSubfrlen; nn++ { + var ret float32 + for i := 0; i < 8; i++ { + s0 := state[srcBase+nn+i] + s1 := state[srcBase+nn+15-i] + ret += (s0 + s1) * celpInterpolKernel[i] + } + state[p+nn] = ret + } + baseLast := p + (celpLagSubfrlen - 1 - il - celpLtpInterpolDelay) + last := celpDotProd(state[baseLast:], celpInterpolKernel[:], 2*celpLtpInterpolDelay) + for i := 0; i < celpLagSubfrlen; i++ { + acbBasis[subfr*celpLagSubfrlen+i] = state[p+i] + } + b1 := (nLags + subfr) * celpLagSubfrlen + acbBasis[b1] = first + state[p+1] + for i := 0; i < celpLagSubfrlen-2; i++ { + acbBasis[b1+1+i] = state[p+i] + state[p+i+2] + } + iLast := celpLagSubfrlen - 1 + acbBasis[b1+iLast] = state[p+iLast-1] + last + } + p += celpLagSubfrlen + } +} + +// --- FCB search helpers ----------------------------------------------------- + +func celpCalcDAbsAndSign(d []float32, l int, dAbs, dSign []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L726-L736 + for i := 0; i < l; i++ { + if d[i] > 0.0 { + dAbs[i] = d[i] + dSign[i] = 1.0 + } else { + dAbs[i] = -d[i] + dSign[i] = -1.0 + } + } +} + +func celpCheckIfBetter(wnrg float32, nrgThr *float32, wnrgPerPulse float32) bool { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L738-L746 + *nrgThr += wnrgPerPulse + if wnrg > *nrgThr { + *nrgThr = wnrg + return true + } + return false +} + +func celpPhiColOffset(col int32) int32 { return int32(smplMaxSfLen) - col } + +func celpNonZeroRange(col int32, percRespLen, fcbSubfrlen int) (int, int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L756-L760 + lo := col - int32(percRespLen) + 1 + if lo < 0 { + lo = 0 + } + hi := col + int32(percRespLen) + if hi > int32(fcbSubfrlen) { + hi = int32(fcbSubfrlen) + } + return int(lo), int(hi) +} + +// CelpSubframeOut is the per-subframe encode result. +type CelpSubframeOut struct { + Pulses [smplCelpMaxRates][]int16 + NPulses [smplCelpMaxRates]int16 + AcbIdx [smplCelpMaxRates]int16 + GainIdx [smplCelpMaxRates]int16 + ExcLpc []float32 +} + +type acbgParams struct { + werrIn float32 + phiAcb [acbgM * acbgM]float32 + dAcbLpc [acbgM]float32 + acbBasisPhi []float32 +} + +type fcb struct { + wnrg float32 + nPulses int32 + posNew int32 + signNew float32 + sgntr uint64 + fcbStateIdx int +} + +type fcbState struct { + pulsePositions []int32 + pulseSigns []float32 + num []float32 + den []float32 +} + +func newFcbState() fcbState { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L715-L724 + return fcbState{ + pulsePositions: make([]int32, smplMaxSfLen), + pulseSigns: make([]float32, smplMaxSfLen), + num: make([]float32, smplMaxSfLen), + den: make([]float32, smplMaxSfLen), + } +} + +func (s *fcbState) cloneFrom(o *fcbState) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L707-L724 + copy(s.pulsePositions, o.pulsePositions) + copy(s.pulseSigns, o.pulseSigns) + copy(s.num, o.num) + copy(s.den, o.den) +} + +// CelpEncoder is the persistent encode-side CELP state. +type CelpEncoder struct { + stateWghtBuf []float32 + stateErrLpcSyn [SmplLPCOrder]float32 + hanningWin []float32 + sgntrs []uint64 + acbState []float32 + acbStateLen int + prevAcbIdx [smplCelpMaxRates]int32 + prevFcbIdx [smplCelpMaxRates]int32 + subfrCnt int32 + subfrPerPacket int32 + fcbSubfrlen int + percRespLen int + lowRate bool + ignoreZir bool + fcbgain float32 + useMa9 bool + + impLpcBuf []float32 + phi []float32 + phiFlip []float32 +} + +// NewCelpEncoder builds the encoder (mirrors CelpEncoder::new). +func NewCelpEncoder(lowRate bool, percRespLen, fcbSubfrlen, subfrPerPacket int) *CelpEncoder { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L816-L871 + _ = getCelpTables() + acbStateLen := fcbSubfrlen + celpMaxpitchLen + celpLtpInterpolDelay + + sgntrs := make([]uint64, smplMaxSfLen) + var s uint64 = 0x9E3779B97F4A7C15 + for i := range sgntrs { + s = s*6364136223846793005 + 1442695040888963407 + sgntrs[i] = s + } + + hanningWin := make([]float32, percRespLen) + scale := 1.0 / float32(2*smplPercRespLen+1) + for i := 0; i < percRespLen; i++ { + hanningWin[i] = float32(math.Sin(float64(smplPI * float32(percRespLen+i+1) * scale))) + } + + e := &CelpEncoder{ + stateWghtBuf: make([]float32, smplMaxSfLen+SmplLPCOrder), + hanningWin: hanningWin, + sgntrs: sgntrs, + acbState: make([]float32, celpMaxPitchLag+smplMaxSfLen+celpLtpInterpolDelay), + acbStateLen: acbStateLen, + prevAcbIdx: [smplCelpMaxRates]int32{-1, -1}, + prevFcbIdx: [smplCelpMaxRates]int32{-1, -1}, + subfrPerPacket: int32(subfrPerPacket), + fcbSubfrlen: fcbSubfrlen, + percRespLen: percRespLen, + lowRate: lowRate, + useMa9: percRespLen == 10, + impLpcBuf: make([]float32, smplMaxSfLen+SmplLPCOrder), + phi: make([]float32, smplMaxSfLen), + phiFlip: make([]float32, 2*smplMaxSfLen), + } + return e +} + +func (e *CelpEncoder) percFiltMa(x []float32, xBase, n int, coef []float32, coefLen int, y []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L873-L888 + if e.useMa9 { + celpFiltMa9(x, xBase, n, coef, coefLen, y) + } else { + celpFiltMa(x, xBase, n, coef, coefLen, y) + } +} + +// --- greedy FCB search (smpl_fcb_search) ------------------------------------ + +func (e *CelpEncoder) smplFcbSearch(d []float32, wnrgPerPulse *[smplCelpMaxRates]float32, fcbPulsesMax *[smplCelpMaxRates]int16, + pulses *[smplCelpMaxRates][smplMaxPulsesPerSf]int16, nPulses *[smplCelpMaxRates]int16, wnrg, gainFromSearch, fcbWnrg *[smplCelpMaxRates]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L946-L1064 + fcbSubfrlen := e.fcbSubfrlen + percRespLen := e.percRespLen + *nPulses = [smplCelpMaxRates]int16{} + + var positions [smplMaxPulsesPerSf]int32 + dAbs := make([]float32, smplMaxSfLen) + dSign := make([]float32, smplMaxSfLen) + num := make([]float32, smplMaxSfLen) + den := make([]float32, smplMaxSfLen) + phi0 := e.phi[0] + celpCalcDAbsAndSign(d, fcbSubfrlen, dAbs, dSign) + + for i := 0; i < fcbSubfrlen; i++ { + den[i] = phi0 + 1e-16 + } + copy(num[:fcbSubfrlen], dAbs[:fcbSubfrlen]) + positions[0] = int32(celpGetMaxi(num, fcbSubfrlen)) + var nrgThr [smplCelpMaxRates]float32 + p0 := int(positions[0]) + ratio := num[p0] / den[p0] + wnrg0 := num[p0] * ratio + if celpCheckIfBetter(wnrg0, &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain]) { + nPulses[smplCelpIdxMain] = 1 + wnrg[smplCelpIdxMain] = wnrg0 + wnrg[smplCelpIdxFec] = wnrg0 + gainFromSearch[smplCelpIdxMain] = ratio + gainFromSearch[smplCelpIdxFec] = ratio + fcbWnrg[smplCelpIdxMain] = den[p0] + fcbWnrg[smplCelpIdxFec] = den[p0] + if fcbPulsesMax[smplCelpIdxFec] > 0 { + nPulses[smplCelpIdxFec] = nPulses[smplCelpIdxMain] + wnrg[smplCelpIdxFec] = wnrg[smplCelpIdxMain] + gainFromSearch[smplCelpIdxFec] = gainFromSearch[smplCelpIdxMain] + fcbWnrg[smplCelpIdxFec] = fcbWnrg[smplCelpIdxMain] + } + } + + for pulseNr := 1; pulseNr < int(fcbPulsesMax[smplCelpIdxMain]); pulseNr++ { + position := positions[pulseNr-1] + sgn := dSign[position] + for i := 0; i < fcbSubfrlen; i++ { + num[i] += dAbs[position] + } + nz0, nz1 := celpNonZeroRange(position, percRespLen, fcbSubfrlen) + colOff := celpPhiColOffset(position) + var dDen float32 + for i := 0; i < pulseNr-1; i++ { + pi := int(positions[i]) + dDen += e.phiFlip[int(colOff)+pi] * dSign[pi] + } + dDen *= 2.0 * sgn + dDen += e.phiFlip[int(colOff+position)] + for i := 0; i < fcbSubfrlen; i++ { + den[i] += dDen + } + for i := nz0; i < nz1; i++ { + den[i] += 2.0 * sgn * dSign[i] * e.phiFlip[int(colOff)+i] + } + q := make([]float32, smplMaxSfLen) + celpQ(num, den, fcbSubfrlen, q) + positions[pulseNr] = int32(celpGetMaxi(q, fcbSubfrlen)) + pp := int(positions[pulseNr]) + if celpCheckIfBetter(q[pp], &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain]) { + nPulses[smplCelpIdxMain] = int16(pulseNr + 1) + wnrg[smplCelpIdxMain] = q[pp] + gainFromSearch[smplCelpIdxMain] = num[pp] / den[pp] + fcbWnrg[smplCelpIdxMain] = den[pp] + } + if int(fcbPulsesMax[smplCelpIdxFec]) >= pulseNr && + celpCheckIfBetter(q[pp], &nrgThr[smplCelpIdxFec], wnrgPerPulse[smplCelpIdxFec]) { + nPulses[smplCelpIdxFec] = int16(pulseNr + 1) + wnrg[smplCelpIdxFec] = q[pp] + gainFromSearch[smplCelpIdxFec] = num[pp] / den[pp] + fcbWnrg[smplCelpIdxFec] = den[pp] + } + } + + for r := smplCelpIdxFec; r <= smplCelpIdxMain; r++ { + if nrgThr[r] > 0.0 { + for i := 0; i < int(nPulses[r]); i++ { + position := positions[i] + if dSign[position] > 0.0 { + pulses[r][i] = 1 + int16(position) + } else { + pulses[r][i] = -(1 + int16(position)) + } + } + } else { + wnrg[r] = 0.0 + gainFromSearch[r] = 0.0 + fcbWnrg[r] = 0.0 + nPulses[r] = 0 + } + } +} + +// --- delayed-decision beam FCB search --------------------------------------- + +type fcbSearchScratch struct { + fcbStates [2][]fcbState + readIdx int + writeIdx int + fcbs []fcb + fcbsSize int + fcbCandidates []fcb + fcbCandidatesSize int + uniqueSgntr []uint64 + uniqueSgntrSize int +} + +func newFcbSearchScratch() *fcbSearchScratch { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L908-L926 + mk := func() []fcbState { + v := make([]fcbState, celpMaxNumsurv) + for i := range v { + v[i] = newFcbState() + } + return v + } + return &fcbSearchScratch{ + fcbStates: [2][]fcbState{mk(), mk()}, + readIdx: 0, + writeIdx: 1, + fcbs: make([]fcb, celpMaxNumsurv), + fcbCandidates: make([]fcb, celpMaxNumsurv*celpMaxNumsurv), + uniqueSgntr: make([]uint64, celpMaxNumsurv*celpMaxNumsurv), + } +} + +func (sc *fcbSearchScratch) swapRw() { sc.readIdx, sc.writeIdx = sc.writeIdx, sc.readIdx } + +func (sc *fcbSearchScratch) isUnique(sgntr uint64) bool { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L928-L930 + for i := 0; i < sc.uniqueSgntrSize; i++ { + if sc.uniqueSgntr[i] == sgntr { + return false + } + } + return true +} + +func (e *CelpEncoder) addPulse(sc *fcbSearchScratch, fcbIdxIn int, dAbs, dSign []float32, numsurv, idx int, lag int32, pitchSharp float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1071-L1242 + fcbSubfrlen := e.fcbSubfrlen + percRespLen := e.percRespLen + + fcbPosNew := sc.fcbs[fcbIdxIn].posNew + fcbSignNew := sc.fcbs[fcbIdxIn].signNew + fcbNPulses := sc.fcbs[fcbIdxIn].nPulses + fcbStateIdx := sc.fcbs[fcbIdxIn].fcbStateIdx + fcbSgntrBase := sc.fcbs[fcbIdxIn].sgntr + + ri := sc.readIdx + wi := sc.writeIdx + + add := dAbs[fcbPosNew] + for i := 0; i < fcbSubfrlen; i++ { + sc.fcbStates[wi][idx].num[i] = sc.fcbStates[ri][fcbStateIdx].num[i] + add + } + for i := 0; i < fcbSubfrlen; i++ { + sc.fcbStates[wi][idx].den[i] = sc.fcbStates[ri][fcbStateIdx].den[i] + } + + if pitchSharp == 0.0 { + nz0, nz1 := celpNonZeroRange(fcbPosNew, percRespLen, fcbSubfrlen) + colOff := celpPhiColOffset(fcbPosNew) + var dDen float32 + for i := 0; i < int(fcbNPulses); i++ { + pos := sc.fcbStates[ri][fcbStateIdx].pulsePositions[i] + sgn := sc.fcbStates[ri][fcbStateIdx].pulseSigns[i] + dDen += e.phiFlip[int(colOff+pos)] * sgn + } + dDen *= 2.0 * fcbSignNew + dDen += e.phiFlip[int(colOff+fcbPosNew)] + for i := 0; i < fcbSubfrlen; i++ { + sc.fcbStates[wi][idx].den[i] += dDen + } + for i := nz0; i < nz1; i++ { + sc.fcbStates[wi][idx].den[i] += 2.0 * fcbSignNew * dSign[i] * e.phiFlip[int(colOff)+i] + } + } else { + var g1 float32 + var dDen float32 + g1 = 1.0 + for pos := fcbPosNew; pos < int32(fcbSubfrlen); pos += lag { + colOff := celpPhiColOffset(pos) + for i := 0; i < int(fcbNPulses); i++ { + g2 := g1 + pulsePos := sc.fcbStates[ri][fcbStateIdx].pulsePositions[i] + pulseSgn := sc.fcbStates[ri][fcbStateIdx].pulseSigns[i] + for posq := pulsePos; posq < int32(fcbSubfrlen); posq += lag { + dDen += g2 * e.phiFlip[int(colOff+posq)] * pulseSgn + g2 *= pitchSharp + } + } + g1 *= pitchSharp + } + dDen *= 2.0 * fcbSignNew + g1 = 1.0 + for pos1 := fcbPosNew; pos1 < int32(fcbSubfrlen); pos1 += lag { + colOff := celpPhiColOffset(pos1) + g2 := g1 + for pos2 := fcbPosNew; pos2 < int32(fcbSubfrlen); pos2 += lag { + dDen += g2 * e.phiFlip[int(colOff+pos2)] + g2 *= pitchSharp + } + g1 *= pitchSharp + } + for i := 0; i < fcbSubfrlen; i++ { + sc.fcbStates[wi][idx].den[i] += dDen + } + ddDen := make([]float32, smplMaxSfLen) + g1 = 1.0 + for pos := fcbPosNew; pos < int32(fcbSubfrlen); pos += lag { + nz0, nz1 := celpNonZeroRange(pos, percRespLen, fcbSubfrlen) + colOff := celpPhiColOffset(pos) + g2 := g1 + for k := int32(0); k < int32(fcbSubfrlen); k += lag { + startI := int32(nz0) - k + if startI < 0 { + startI = 0 + } + endI := int32(fcbSubfrlen) - k + if int32(nz1)-k < endI { + endI = int32(nz1) - k + } + for i := startI; i < endI; i++ { + ddDen[i] += g2 * e.phiFlip[int(colOff+i+k)] + } + g2 *= pitchSharp + } + g1 *= pitchSharp + } + for i := 0; i < fcbSubfrlen; i++ { + sc.fcbStates[wi][idx].den[i] += 2.0 * fcbSignNew * dSign[i] * ddDen[i] + } + } + + for i := 0; i < int(fcbNPulses); i++ { + sc.fcbStates[wi][idx].pulsePositions[i] = sc.fcbStates[ri][fcbStateIdx].pulsePositions[i] + sc.fcbStates[wi][idx].pulseSigns[i] = sc.fcbStates[ri][fcbStateIdx].pulseSigns[i] + } + sc.fcbStates[wi][idx].pulsePositions[fcbNPulses] = fcbPosNew + sc.fcbStates[wi][idx].pulseSigns[fcbNPulses] = fcbSignNew + + newNPulses := fcbNPulses + 1 + q := make([]float32, smplMaxSfLen) + celpQ(sc.fcbStates[wi][idx].num, sc.fcbStates[wi][idx].den, fcbSubfrlen, q) + var sortIx [celpMaxNumsurv]int32 + celpGetMaxiK(q, sortIx[:], fcbSubfrlen, numsurv) + for i := 0; i < numsurv; i++ { + pos := int(sortIx[i]) + sgntr := fcbSgntrBase + e.sgntrs[pos] + if sc.isUnique(sgntr) { + sc.fcbCandidates[sc.fcbCandidatesSize] = fcb{wnrg: q[pos], nPulses: newNPulses, posNew: int32(pos), signNew: dSign[pos], sgntr: sgntr, fcbStateIdx: idx} + sc.fcbCandidatesSize++ + sc.uniqueSgntr[sc.uniqueSgntrSize] = sgntr + sc.uniqueSgntrSize++ + } + } +} + +func (e *CelpEncoder) smplFcbSearchDeldec(d []float32, pitchSharp float32, lag int32, wnrgPerPulse *[smplCelpMaxRates]float32, fcbPulsesMax *[smplCelpMaxRates]int16, surv []int16, + pulses *[smplCelpMaxRates][smplMaxPulsesPerSf]int16, nPulses *[smplCelpMaxRates]int16, wnrg, gainFromSearch, fcbWnrg *[smplCelpMaxRates]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1247-L1494 + fcbSubfrlen := e.fcbSubfrlen + sc := newFcbSearchScratch() + + dNew := make([]float32, smplMaxSfLen) + dAbs := make([]float32, smplMaxSfLen) + dSign := make([]float32, smplMaxSfLen) + phi0 := e.phi[0] + + if pitchSharp != 0.0 && lag > 0 && lag < int32(fcbSubfrlen) { + copy(dNew[:fcbSubfrlen], d[:fcbSubfrlen]) + for j := 0; j < fcbSubfrlen; j++ { + g := pitchSharp + for i := lag + int32(j); i < int32(fcbSubfrlen); i += lag { + dNew[j] += g * d[i] + g *= pitchSharp + } + } + celpCalcDAbsAndSign(dNew, fcbSubfrlen, dAbs, dSign) + } else { + celpCalcDAbsAndSign(d, fcbSubfrlen, dAbs, dSign) + pitchSharp = 0.0 + } + + sc.readIdx = 0 + sc.writeIdx = 1 + var bestFcb [smplCelpMaxRates]fcb + bestFcbState := [smplCelpMaxRates]fcbState{newFcbState(), newFcbState()} + var nrgThr [smplCelpMaxRates]float32 + + { + wi := sc.writeIdx + copy(sc.fcbStates[wi][0].num[:fcbSubfrlen], dAbs[:fcbSubfrlen]) + if pitchSharp == 0.0 { + for i := 0; i < fcbSubfrlen; i++ { + sc.fcbStates[wi][0].den[i] = phi0 + 1e-16 + } + } else { + offset := int32(fcbSubfrlen) - 1 + for i := int32(fcbSubfrlen) - 1; i >= 0; i -= lag { + res := float32(1e-16) + g1 := float32(1.0) + for j := i; j < int32(fcbSubfrlen); j += lag { + colOff := celpPhiColOffset(j) + g2 := float32(1.0) + for k := i; k < int32(fcbSubfrlen); k += lag { + res += g1 * g2 * e.phiFlip[int(colOff+k)] + g2 *= pitchSharp + } + g1 *= pitchSharp + } + length := lag + if offset+1 < length { + length = offset + 1 + } + for jj := int32(0); jj < length; jj++ { + sc.fcbStates[wi][0].den[offset-jj] = res + } + offset -= length + } + } + } + + sc.swapRw() + q := make([]float32, smplMaxSfLen) + { + ri := sc.readIdx + if pitchSharp == 0.0 { + copy(q[:fcbSubfrlen], sc.fcbStates[ri][0].num[:fcbSubfrlen]) + } else { + celpQ(sc.fcbStates[ri][0].num, sc.fcbStates[ri][0].den, fcbSubfrlen, q) + } + } + + var sortIx [celpMaxNumsurv]int32 + celpGetMaxiK(q, sortIx[:], fcbSubfrlen, int(surv[0])) + sc.fcbsSize = 0 + { + ri := sc.readIdx + for i := 0; i < int(surv[0]); i++ { + pos := int(sortIx[i]) + sc.fcbs[sc.fcbsSize] = fcb{ + sgntr: e.sgntrs[pos], + posNew: int32(pos), + signNew: dSign[pos], + wnrg: (sc.fcbStates[ri][0].num[pos] * sc.fcbStates[ri][0].num[pos]) / sc.fcbStates[ri][0].den[pos], + } + sc.fcbsSize++ + } + } + + e.checkIfBetterDeldec(sc, false, 0, &bestFcb[smplCelpIdxMain], &bestFcbState[smplCelpIdxMain], &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain]) + if fcbPulsesMax[smplCelpIdxFec] > 0 { + e.checkIfBetterDeldec(sc, false, 0, &bestFcb[smplCelpIdxFec], &bestFcbState[smplCelpIdxFec], &nrgThr[smplCelpIdxFec], wnrgPerPulse[smplCelpIdxFec]) + } + + if fcbPulsesMax[smplCelpIdxMain] > 1 { + for pulseNr := 2; pulseNr < int(fcbPulsesMax[smplCelpIdxMain]); pulseNr++ { + sc.fcbCandidatesSize = 0 + sc.uniqueSgntrSize = 0 + fcbsSize := sc.fcbsSize + for i := 0; i < fcbsSize; i++ { + e.addPulse(sc, i, dAbs, dSign, int(surv[pulseNr-1]), i, lag, pitchSharp) + } + sc.swapRw() + candSize := sc.fcbCandidatesSize + for i := 0; i < candSize; i++ { + q[i] = sc.fcbCandidates[i].wnrg + } + celpGetMaxiK(q, sortIx[:], candSize, int(surv[pulseNr-1])) + sc.fcbsSize = 0 + for i := 0; i < int(surv[pulseNr-1]); i++ { + sc.fcbs[sc.fcbsSize] = sc.fcbCandidates[sortIx[i]] + sc.fcbsSize++ + } + e.checkIfBetterDeldec(sc, false, 0, &bestFcb[smplCelpIdxMain], &bestFcbState[smplCelpIdxMain], &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain]) + if int(fcbPulsesMax[smplCelpIdxFec]) >= pulseNr { + e.checkIfBetterDeldec(sc, false, 0, &bestFcb[smplCelpIdxFec], &bestFcbState[smplCelpIdxFec], &nrgThr[smplCelpIdxFec], wnrgPerPulse[smplCelpIdxFec]) + } + } + sc.fcbCandidatesSize = 0 + sc.uniqueSgntrSize = 0 + fcbsSize := sc.fcbsSize + for i := 0; i < fcbsSize; i++ { + e.addPulse(sc, i, dAbs, dSign, 1, i, lag, pitchSharp) + } + sc.swapRw() + bestIdx := 0 + maxWnrg := sc.fcbCandidates[0].wnrg + for i := 1; i < sc.fcbCandidatesSize; i++ { + if sc.fcbCandidates[i].wnrg > maxWnrg { + maxWnrg = sc.fcbCandidates[i].wnrg + bestIdx = i + } + } + e.checkIfBetterDeldec(sc, true, bestIdx, &bestFcb[smplCelpIdxMain], &bestFcbState[smplCelpIdxMain], &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain]) + } + + for r := smplCelpIdxFec; r <= smplCelpIdxMain; r++ { + for i := 0; i < int(bestFcb[r].nPulses); i++ { + if bestFcbState[r].pulseSigns[i] > 0.0 { + pulses[r][i] = 1 + int16(bestFcbState[r].pulsePositions[i]) + } else { + pulses[r][i] = -(1 + int16(bestFcbState[r].pulsePositions[i])) + } + } + if bestFcb[r].signNew > 0.0 { + pulses[r][bestFcb[r].nPulses] = 1 + int16(bestFcb[r].posNew) + } else { + pulses[r][bestFcb[r].nPulses] = -(1 + int16(bestFcb[r].posNew)) + } + if bestFcb[r].wnrg > 0.0 { + wnrg[r] = bestFcb[r].wnrg + pn := int(bestFcb[r].posNew) + gainFromSearch[r] = bestFcbState[r].num[pn] / bestFcbState[r].den[pn] + fcbWnrg[r] = bestFcbState[r].den[pn] + nPulses[r] = int16(bestFcb[r].nPulses) + 1 + } else { + wnrg[r] = 0.0 + gainFromSearch[r] = 0.0 + fcbWnrg[r] = 0.0 + nPulses[r] = 0 + } + } +} + +// checkIfBetterDeldec: fromCand selects sc.fcbCandidates[idx] vs sc.fcbs[idx]. +func (e *CelpEncoder) checkIfBetterDeldec(sc *fcbSearchScratch, fromCand bool, idx int, bestFcb *fcb, bestFcbState *fcbState, nrgThr *float32, wnrgPerPulse float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1497-L1532 + *nrgThr += wnrgPerPulse + var f *fcb + if fromCand { + f = &sc.fcbCandidates[idx] + } else { + f = &sc.fcbs[idx] + } + if f.wnrg > *nrgThr { + *nrgThr = f.wnrg + *bestFcb = *f + bestFcbState.cloneFrom(&sc.fcbStates[sc.readIdx][f.fcbStateIdx]) + } +} + +// --- gain quant ------------------------------------------------------------- + +func celpWnrg2(c, x []float32) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1540-L1542 + return x[0]*(c[0]*x[0]+c[1]*x[1]) + x[1]*(c[2]*x[0]+c[3]*x[1]) +} + +func celpWnrg3(c, x []float32) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1545-L1549 + return x[0]*(c[0]*x[0]+c[1]*x[1]+c[2]*x[2]) + + x[1]*(c[3]*x[0]+c[4]*x[1]+c[5]*x[2]) + + x[2]*(c[6]*x[0]+c[7]*x[1]+c[8]*x[2]) +} + +func celpQuantGainUv(gainFromSearch float32) int16 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1552-L1556 + gainDb := 20.0 * float32(math.Log10(float64(gainFromSearch+1.0e-16))) + if gainDb < uvGainMinDb { + gainDb = uvGainMinDb + } + if gainDb > uvGainMaxDb { + gainDb = uvGainMaxDb + } + return int16(math.Round(float64((gainDb - uvGainMinDb) / uvGainStepDb))) +} + +func celpFcbSynthesize(fcbSubfrlen int, pulses []int16, nPulses int, fcb []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1558-L1568 + for i := 0; i < fcbSubfrlen; i++ { + fcb[i] = 0.0 + } + for n := 0; n < nPulses; n++ { + sign := int32(1) + 2*(int32(pulses[n])>>15) + pos := int32(pulses[n])*sign - 1 + fcb[pos] += float32(sign) + } +} + +func (e *CelpEncoder) calcAcbGain(lResp int, acbBasis, dLpc []float32, acbg *acbgParams, dLtp []float32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1572-L1646 + fcbSubfrlen := e.fcbSubfrlen + for m := 0; m < acbgM; m++ { + cOff := smplMaxSfLen - lResp + 1 + tmp := make([]float32, fcbSubfrlen) + celpMultSymtoepl2(e.phiFlip[cOff:], lResp, acbBasis[m*fcbSubfrlen:], tmp, fcbSubfrlen) + copy(acbg.acbBasisPhi[m*fcbSubfrlen:m*fcbSubfrlen+fcbSubfrlen], tmp) + for i := 0; i < acbgM; i++ { + acbg.phiAcb[m*acbgM+i] = celpDotProd(acbBasis[i*fcbSubfrlen:], acbg.acbBasisPhi[m*fcbSubfrlen:], fcbSubfrlen) + } + acbg.dAcbLpc[m] = celpDotProd(acbBasis[m*fcbSubfrlen:], dLpc, fcbSubfrlen) + } + + bestRd := float32(1e30) + bestAcbgIdx := int32(0) + transitionIdx := int32(0) + if e.prevAcbIdx[smplCelpIdxMain] != -1 { + transitionIdx = e.prevAcbIdx[smplCelpIdxMain] + 1 + } + invProbFull := e.acbgInvProb() + invProb := invProbFull[int(transitionIdx)*acbgN:] + cb := &cbAcbgainsHRQ14 + if e.lowRate { + cb = &cbAcbgainsLRQ14 + } + scQ14 := 1.0 / float32(int32(1)<<14) + var acbGains [acbgM]float32 + for n := 0; n < acbgN; n++ { + for m := 0; m < acbgM; m++ { + acbGains[m] = float32(cb[n*acbgM+m]) * scQ14 + } + werrOut := acbg.werrIn + celpWnrg2(acbg.phiAcb[:], acbGains[:]) - + 2.0*(acbg.dAcbLpc[0]*acbGains[0]+acbg.dAcbLpc[1]*acbGains[1]) + rd := werrOut * invProb[n] + if rd < bestRd { + bestRd = rd + bestAcbgIdx = int32(n) + } + } + + g0 := -float32(cb[int(bestAcbgIdx)*acbgM]) * scQ14 + celpAddScaleVec(dLpc, acbg.acbBasisPhi, dLtp, fcbSubfrlen, g0) + g1 := -float32(cb[int(bestAcbgIdx)*acbgM+1]) * scQ14 + celpAddScaleVecInplace(acbg.acbBasisPhi[fcbSubfrlen:], dLtp, fcbSubfrlen, g1) + return bestAcbgIdx +} + +func (e *CelpEncoder) acbgInvProb() []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1613-L1618 + if e.lowRate { + return getCelpTables().acbgInvProbLR[:] + } + return getCelpTables().acbgInvProbHR[:] +} + +func (e *CelpEncoder) calcGainsV(fcbWnrg, gainFromSearch float32, excFcb, dLpc []float32, acbg *acbgParams, rateIdx int, acbIdx, fcbIdx *[smplCelpMaxRates]int16) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1649-L1761 + tbl := getCelpTables() + fcbSubfrlen := e.fcbSubfrlen + + fcbgain := gainFromSearch + if fcbgain < 0.0 { + fcbgain = 0.0 + } + gainDb := 20.0 * float32(math.Log10(float64(fcbgain+1.0e-16))) + if gainDb < vGainMinDb { + gainDb = vGainMinDb + } + if gainDb > vGainMaxDb { + gainDb = vGainMaxDb + } + maxGainIdx := int32(math.Round(float64((vGainMaxDb - vGainMinDb) / vGainStepDb))) + + bestAcbgIdx := int32(0) + bestFcbgIdx := int32(0) + + var acbFcb [acbgM]float32 + for i := 0; i < acbgM; i++ { + acbFcb[i] = celpDotProd(acbg.acbBasisPhi[i*fcbSubfrlen:], excFcb, fcbSubfrlen) + } + var phiAll [(acbgM + 1) * (acbgM + 1)]float32 + stride := acbgM + 1 + for i := 0; i < acbgM; i++ { + for j := 0; j < acbgM; j++ { + phiAll[i*stride+j] = acbg.phiAcb[i*acbgM+j] + } + } + for i := 0; i < acbgM; i++ { + phiAll[i*stride+acbgM] = acbFcb[i] + phiAll[acbgM*stride+i] = acbFcb[i] + } + phiAll[acbgM*stride+acbgM] = fcbWnrg + + var dall [acbgM + 1]float32 + copy(dall[:acbgM], acbg.dAcbLpc[:]) + dall[acbgM] = celpDotProd(dLpc, excFcb, fcbSubfrlen) + + var gainIdxs [nGainSteps]int32 + var fcbgains [nGainSteps]float32 + var fcbgInvProb [nGainSteps]float32 + firstGainIdx := int32(math.Floor(float64((gainDb-vGainMinDb)/vGainStepDb))) - (nGainSteps-1)/2 + if firstGainIdx < 0 { + firstGainIdx = 0 + } + if firstGainIdx > maxGainIdx-1 { + firstGainIdx = maxGainIdx - 1 + } + offset := int32(math.Floor(float64((vGainMinDb - vGainMaxDb) / vGainStepDb))) + for i := 0; i < nGainSteps; i++ { + gainIdxs[i] = firstGainIdx + int32(i) + fcbgains[i] = tbl.fcbgainsV[gainIdxs[i]] + if e.prevFcbIdx[rateIdx] == -1 { + fcbgInvProb[i] = tbl.fcbgVInvProb[gainIdxs[i]] + } else { + delta := e.prevFcbIdx[rateIdx] - gainIdxs[i] + cmfIdx := delta - offset + fcbgInvProb[i] = tbl.fcbgVDeltaInvProb[cmfIdx] + } + } + + bestRd := float32(1e30) + transitionIdx := int32(0) + if e.prevAcbIdx[rateIdx] != -1 { + transitionIdx = e.prevAcbIdx[rateIdx] + 1 + } + cb := &cbAcbgainsHRQ14 + if e.lowRate { + cb = &cbAcbgainsLRQ14 + } + invProb := e.acbgInvProb()[int(transitionIdx)*acbgN:] + scQ14 := 1.0 / float32(int32(1)<<14) + for n := 0; n < acbgN; n++ { + var gains [acbgM + 1]float32 + for m := 0; m < acbgM; m++ { + gains[m] = float32(cb[n*acbgM+m]) * scQ14 + } + for i := 0; i < nGainSteps; i++ { + gains[acbgM] = fcbgains[i] + werrOut := acbg.werrIn + celpWnrg3(phiAll[:], gains[:]) - + 2.0*(dall[0]*gains[0]+dall[1]*gains[1]+dall[2]*gains[2]) + rd := werrOut * fcbgInvProb[i] * invProb[n] + if rd < bestRd { + bestRd = rd + bestAcbgIdx = int32(n) + bestFcbgIdx = gainIdxs[i] + } + } + } + acbIdx[rateIdx] = int16(bestAcbgIdx) + fcbIdx[rateIdx] = int16(bestFcbgIdx) + if fcbIdx[rateIdx] < 0 { + fcbIdx[rateIdx] = 0 + } + if fcbIdx[rateIdx] > int16(maxGainIdx) { + fcbIdx[rateIdx] = int16(maxGainIdx) + } + return tbl.fcbgainsV[fcbIdx[rateIdx]] +} + +// EncodeSubframe is the main per-subframe CELP encoder (smpl_celp_encoder). +func (e *CelpEncoder) EncodeSubframe(resLpc []float32, predcoef *[17]float32, percWghtResp, lags []float32, subfrImportance [smplCelpMaxRates]float32, fcbPulsesMax [smplCelpMaxRates]int16, surv []int16) CelpSubframeOut { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1769-L2148 + lResp := e.percRespLen + fcbSubfrlen := e.fcbSubfrlen + voiced := lags[1] > 0.0 + + celpFiltAr16(percWghtResp, lResp, predcoef[:], SmplLPCOrder, e.impLpcBuf) + celpMulVecInplace(e.hanningWin, e.impLpcBuf[SmplLPCOrder:], lResp) + + impLpcRev := make([]float32, 2*smplMaxLResp-1) + revBase := smplMaxLResp - 1 + { + imp := e.impLpcBuf[SmplLPCOrder:] + for i := 0; i < lResp; i++ { + impLpcRev[revBase+i] = imp[lResp-i-1] + } + } + { + imp := append([]float32(nil), e.impLpcBuf[SmplLPCOrder:SmplLPCOrder+lResp]...) + phi := make([]float32, smplMaxSfLen) + e.percFiltMa(impLpcRev, revBase, lResp, imp, lResp, phi) + celpReverse(phi, lResp) + for i := lResp; i < fcbSubfrlen; i++ { + phi[i] = 0.0 + } + copy(e.phi, phi) + } + for i := range e.phiFlip { + e.phiFlip[i] = 0.0 + } + e.phiFlip[smplMaxSfLen] = e.phi[0] + for i := 0; i < lResp+1; i++ { + e.phiFlip[smplMaxSfLen-i] = e.phi[i] + e.phiFlip[smplMaxSfLen+i] = e.phi[i] + } + + resLpcPad := make([]float32, fcbSubfrlen+lResp+1) + copy(resLpcPad[:fcbSubfrlen], resLpc[:fcbSubfrlen]) + dLpc := make([]float32, smplMaxSfLen) + { + cOff := smplMaxSfLen - lResp + 1 + celpMultSymtoepl2(e.phiFlip[cOff:], lResp, resLpcPad, dLpc, fcbSubfrlen) + } + + acbg := acbgParams{acbBasisPhi: make([]float32, acbgM*fcbSubfrlen)} + zirLpc := make([]float32, smplMaxSfLen) + + if !e.ignoreZir { + zirTmp := make([]float32, smplMaxSfLen+smplMaxLResp-1) + zt := smplMaxLResp - 1 + htZir := make([]float32, 2*smplMaxLResp-1) + ht := smplMaxLResp - 1 + + stateLen := SmplLPCOrder + if lResp-1 > stateLen { + stateLen = lResp - 1 + } + for i := 0; i < stateLen; i++ { + zirTmp[zt-stateLen+i] = e.stateWghtBuf[SmplLPCOrder+(fcbSubfrlen-stateLen)+i] + } + for nn := 0; nn < lResp; nn++ { + res := zirTmp[zt+nn] + for i := 0; i < 16; i++ { + res -= predcoef[16-i] * zirTmp[zt+nn-16+i] + } + zirTmp[zt+nn] = res + } + e.percFiltMa(zirTmp, zt, lResp, percWghtResp, lResp, zirLpc) + for i := 0; i < lResp; i++ { + zirTmp[zt+i] = zirLpc[lResp-i-1] + } + for i := 0; i < lResp-1; i++ { + zirTmp[zt-(lResp-1)+i] = 0.0 + } + { + imp := append([]float32(nil), e.impLpcBuf[SmplLPCOrder:SmplLPCOrder+lResp]...) + e.percFiltMa(zirTmp, zt, lResp, imp, lResp, htZir[ht:]) + } + celpReverse(htZir[ht:], lResp) + + if voiced { + acbg.werrIn = celpDotProd(dLpc, resLpc, fcbSubfrlen) + + 2.0*celpDotProd(htZir[ht:], resLpc, lResp) + celpNrg(zirLpc, lResp) + } + for i := 0; i < lResp; i++ { + dLpc[i] += htZir[ht+i] + } + } else { + for i := 0; i < lResp; i++ { + zirLpc[i] = 0.0 + } + if voiced { + acbg.werrIn = celpDotProd(dLpc, resLpc, fcbSubfrlen) + } + } + + acbBasis := make([]float32, smplMaxSfLen*acbgM) + acb := make([]float32, smplMaxSfLen) + dLtp := make([]float32, smplMaxSfLen) + acbIdx := [smplCelpMaxRates]int16{-1, -1} + + if voiced { + celpSynLtpBasis(lags, fcbSubfrlen/celpLagSubfrlen, e.acbState, e.acbStateLen, acbBasis) + idx := e.calcAcbGain(lResp, acbBasis, dLpc, &acbg, dLtp) + acbIdx[smplCelpIdxMain] = int16(idx) + var acbGain [acbgM]float32 + celpAcbDequant(e.lowRate, int32(acbIdx[smplCelpIdxMain]), &acbGain) + celpAcbSynthesize(fcbSubfrlen, acbBasis, &acbGain, acb) + acbIdx[smplCelpIdxFec] = acbIdx[smplCelpIdxMain] + } + + wtgtTmp := make([]float32, smplMaxSfLen+2*smplMaxLResp-1) + wt := smplMaxLResp - 1 + wtgt := make([]float32, smplMaxSfLen+smplMaxLResp) + copy(wtgtTmp[wt:wt+fcbSubfrlen], resLpc[:fcbSubfrlen]) + if voiced { + for i := 0; i < fcbSubfrlen; i++ { + wtgtTmp[wt+i] += -rateAcbScale * acb[i] + } + } + { + imp := append([]float32(nil), e.impLpcBuf[SmplLPCOrder:SmplLPCOrder+lResp]...) + e.percFiltMa(wtgtTmp, wt, fcbSubfrlen+lResp, imp, lResp, wtgt) + } + for i := 0; i < lResp; i++ { + wtgt[i] += zirLpc[i] + } + nrgWtgt := celpNrg(wtgt, fcbSubfrlen+lResp) + var wnrgPerPulse [smplCelpMaxRates]float32 + for r := 0; r < smplCelpMaxRates; r++ { + wnrgPerPulse[r] = nrgWtgt / (subfrImportance[r] + 1.0e-3) + } + iLag := int32(lags[(fcbSubfrlen/celpLagSubfrlen)-1]) + + var nPulses [smplCelpMaxRates]int16 + var gainFromSearch [smplCelpMaxRates]float32 + var fcbWnrg [smplCelpMaxRates]float32 + var wnrg [smplCelpMaxRates]float32 + var pulses [smplCelpMaxRates][smplMaxPulsesPerSf]int16 + + if fcbPulsesMax[smplCelpIdxMain] > 0 { + target := dLpc + if voiced { + target = dLtp + } + useGreedy := fcbPulsesMax[smplCelpIdxMain]-1 > 0 && + surv[fcbPulsesMax[smplCelpIdxMain]-2] == 1 && !e.lowRate + if useGreedy { + e.smplFcbSearch(target, &wnrgPerPulse, &fcbPulsesMax, &pulses, &nPulses, &wnrg, &gainFromSearch, &fcbWnrg) + } else { + ps := float32(0.0) + if e.lowRate { + ps = pitchSharpeningCoef + } + e.smplFcbSearchDeldec(target, ps, iLag, &wnrgPerPulse, &fcbPulsesMax, surv, &pulses, &nPulses, &wnrg, &gainFromSearch, &fcbWnrg) + } + } + + gainIdx := [smplCelpMaxRates]int16{-1, -1} + var fcbgain float32 + excFcb := make([]float32, smplMaxSfLen) + tbl := getCelpTables() + for r := 0; r < smplCelpMaxRates; r++ { + excFcbRaw := make([]float32, smplMaxSfLen) + celpFcbSynthesize(fcbSubfrlen, pulses[r][:], int(nPulses[r]), excFcbRaw) + copy(excFcb[:fcbSubfrlen], excFcbRaw[:fcbSubfrlen]) + if nPulses[r] > 0 { + if voiced { + if e.lowRate { + celpPitchSharp(excFcb, int(iLag), fcbSubfrlen) + } + fcbgain = e.calcGainsV(fcbWnrg[r], gainFromSearch[r], excFcb, dLpc, &acbg, r, &acbIdx, &gainIdx) + } else { + gainIdx[r] = celpQuantGainUv(gainFromSearch[r]) + fcbgain = tbl.fcbgainsUV[gainIdx[r]] + } + celpScaleVecInplace(excFcb, fcbSubfrlen, fcbgain) + } + } + + excLpc := make([]float32, fcbSubfrlen) + copy(excLpc, excFcb[:fcbSubfrlen]) + if voiced { + var acbGain [acbgM]float32 + celpAcbDequant(e.lowRate, int32(acbIdx[smplCelpIdxMain]), &acbGain) + celpAcbSynthesize(fcbSubfrlen, acbBasis, &acbGain, acb) + celpAddVecInplace(acb, excLpc, fcbSubfrlen) + } + + copy(e.acbState[0:e.acbStateLen-fcbSubfrlen], e.acbState[fcbSubfrlen:e.acbStateLen]) + writeOff := e.acbStateLen - 2*fcbSubfrlen + copy(e.acbState[writeOff:writeOff+fcbSubfrlen], excLpc[:fcbSubfrlen]) + + if !e.ignoreZir { + lpcResErr := make([]float32, smplMaxSfLen) + celpSubVec(resLpc, excLpc, lpcResErr, fcbSubfrlen) + for i := 0; i < SmplLPCOrder; i++ { + e.stateWghtBuf[i] = e.stateErrLpcSyn[i] + } + celpFiltAr16(lpcResErr, fcbSubfrlen, predcoef[:], SmplLPCOrder, e.stateWghtBuf) + for i := 0; i < SmplLPCOrder; i++ { + e.stateErrLpcSyn[i] = e.stateWghtBuf[SmplLPCOrder+(fcbSubfrlen-SmplLPCOrder)+i] + } + } + + e.subfrCnt++ + if e.subfrCnt == e.subfrPerPacket { + for r := 0; r < smplCelpMaxRates; r++ { + e.prevAcbIdx[r] = -1 + e.prevFcbIdx[r] = -1 + } + e.subfrCnt = 0 + } else { + for r := 0; r < smplCelpMaxRates; r++ { + if voiced { + e.prevAcbIdx[r] = int32(acbIdx[r]) + e.prevFcbIdx[r] = int32(gainIdx[r]) + } else { + e.prevAcbIdx[r] = -1 + e.prevFcbIdx[r] = -1 + } + } + } + e.fcbgain = fcbgain + + nFec := int(nPulses[smplCelpIdxFec]) + if nFec < 0 { + nFec = 0 + } + nMain := int(nPulses[smplCelpIdxMain]) + if nMain < 0 { + nMain = 0 + } + pulsesFec := append([]int16(nil), pulses[smplCelpIdxFec][:nFec]...) + pulsesMain := append([]int16(nil), pulses[smplCelpIdxMain][:nMain]...) + + return CelpSubframeOut{ + Pulses: [smplCelpMaxRates][]int16{pulsesFec, pulsesMain}, + NPulses: nPulses, + AcbIdx: acbIdx, + GainIdx: gainIdx, + ExcLpc: excLpc, + } +} + +// smplDistributeFcbSurv splits tot_surv survivors across pulse counts. +func smplDistributeFcbSurv(numsurv []int16, maxPulses, totSurv int32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L2155-L2182 + if maxPulses <= 1 { + numsurv[0] = 1 + return + } + for i := 0; i < int(maxPulses); i++ { + numsurv[i] = 1 + } + sumSurv := maxPulses + extraSurv := totSurv - maxPulses + extra := extraSurv / (maxPulses - 1) + if extra > fcbSrvMax-1 { + extra = fcbSrvMax - 1 + } + for i := 0; i < int(maxPulses-1); i++ { + numsurv[i] += int16(extra) + } + sumSurv += extra * (maxPulses - 1) + ix := maxPulses - 2 + for sumSurv < totSurv { + if int32(numsurv[ix]) < fcbSrvMax { + numsurv[ix]++ + sumSurv++ + } + ix-- + if ix < 0 { + break + } + } +} diff --git a/pkg/call/voip/media/mlow/celpdec.go b/pkg/call/voip/media/mlow/celpdec.go new file mode 100644 index 00000000..61b07183 --- /dev/null +++ b/pkg/call/voip/media/mlow/celpdec.go @@ -0,0 +1,352 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "math" + "sync" +) + +// Decoder-side CELP synthesis in the codec's native float domain — a faithful port +// of the per-subframe loop in smpl_core_decoder.c (excitation → CELP/ACB decode → +// gen_noise → LPC synthesis) plus LSF interpolation and the FCB gain tables. Output +// is float in [-1, 1]. Reuses SmplNLSF2A (synth), the noise generator (noise.go), +// and the HP postfilter (postfilter.go). + +const ( + celpLagSubfrLen = 40 + celpLTPInterpolDelay = 8 + celpMaxPitchLag = 320 + acbgM = 2 + acbgN = 16 + pitchSharpCoef = float32(0.9881) + fcbgVN = 34 + uvGainIdxLen = 90 + vGainMinDB = float32(-100.0) + vGainStepDB = float32(3.0) + uvGainMinDB = float32(-90.0) + uvGainStepDB = float32(1.0) +) + +// decAcbHighBoost: ACB high-boost endpoints (smpl_dec_acb_high_boost). +var decAcbHighBoost = [2]float32{0.35, 0.18} + +// lsfInterpol4: LSF→LPC interpolation factors per subframe, [lsf_interpol_idx][sf]. +var lsfInterpol4 = [2][4]float32{{0.55, 0.88, 1.0, 1.0}, {0.3, 0.65, 0.95, 1.0}} + +// celpInterpolKernel: 16-tap symmetric LTP interpolation kernel. +var celpInterpolKernel = [2 * celpLTPInterpolDelay]float32{ + -6.3925986e-6, 0.00011064114, -0.0009153038, 0.00484772, -0.018698348, 0.05759091, -0.15997477, 0.6170455, + 0.61704546, -0.15997475, 0.057590906, -0.018698348, 0.00484772, -0.0009153038, 0.000110641144, -6.392598e-6, +} + +// Per-subframe ACB-gain codebook (Q14), [acbgN*acbgM]. Mirrors smpl_celp's +// cb_acbgains_{hr,lr}_q14 (only these two small tables are needed on the decode path). +var cbAcbgainsHRQ14 = [acbgN * acbgM]int16{ + 16039, 91, 0, 0, 4310, 4930, -1431, 2862, 2893, 0, 8009, 4075, 2754, 4223, 8367, 354, + 4640, 1254, -176, 2734, -1222, 5017, -476, 1506, 11351, 567, 1243, 0, 10601, 22, 14088, 108, +} +var cbAcbgainsLRQ14 = [acbgN * acbgM]int16{ + 2812, 2484, 0, 0, -362, 2465, -337, 703, 3033, 1474, 13536, 220, -2630, 9226, 6032, 3499, + -220, 441, 7661, 4243, 11521, 0, 1430, 779, 4495, 2724, 15535, 343, -779, 1559, 480, 481, +} + +type fcbGainsT struct { + uv [uvGainIdxLen + 1]float32 + v [fcbgVN]float32 +} + +var ( + fcbGainsOnce sync.Once + fcbGainsV fcbGainsT +) + +func fcbGains() *fcbGainsT { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L64-L77 + fcbGainsOnce.Do(func() { + for ix := 0; ix <= uvGainIdxLen; ix++ { + fcbGainsV.uv[ix] = float32(math.Pow(10, float64(0.05*(float32(ix)*uvGainStepDB+uvGainMinDB)))) + } + for ix := 0; ix < fcbgVN; ix++ { + fcbGainsV.v[ix] = float32(math.Pow(10, float64(0.05*(float32(ix)*vGainStepDB+vGainMinDB)))) + } + }) + return &fcbGainsV +} + +func celpDot(a, b []float32, l int) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L79-L86 + var r float32 + for i := 0; i < l; i++ { + r += a[i] * b[i] + } + return r +} + +// lpcInterpol: per-subframe interpolation of the LSF between prevLsf and lsf, then +// NLSF→A. Mutates prevLsf to the last interpolated LSF (carried across frames). +func lpcInterpol(lsf []float32, prevLsf *[SmplOrder]float32, interpol [4]float32, aOut *[SmplSubfrCount][SmplOrder + 1]float32, lsfsOut *[SmplSubfrCount][SmplOrder]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L126-L155 + if prevLsf[SmplOrder-1] == 0.0 { + copy(prevLsf[:], lsf[:SmplOrder]) + } + var ilsf [SmplOrder]float32 + prevFactor := float32(-1.0) + for j := 0; j < SmplSubfrCount; j++ { + if interpol[j] == prevFactor { + aOut[j] = aOut[j-1] + } else { + if interpol[j] == 1.0 { + copy(ilsf[:], lsf[:SmplOrder]) + } else { + for k := 0; k < SmplOrder; k++ { + ilsf[k] = prevLsf[k]*(1.0-interpol[j]) + lsf[k]*interpol[j] + } + } + copy(aOut[j][:], SmplNLSF2A(ilsf[:])) + } + prevFactor = interpol[j] + lsfsOut[j] = ilsf + } + copy(prevLsf[:], ilsf[:]) +} + +func acbDequant(lowRate bool, acbIdx int32, acbG *[acbgM]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L157-L168 + cb := &cbAcbgainsHRQ14 + if lowRate { + cb = &cbAcbgainsLRQ14 + } + const sc = 1.0 / float32(int32(1)<<14) + for m := 0; m < acbgM; m++ { + acbG[m] = float32(cb[int(acbIdx)*acbgM+m]) * sc + } +} + +// acbSynthesize: adjust_acbgains (high-boost) then 3-tap symmetric ACB synthesis. +func acbSynthesize(fcbSubfrlen int, acbBasis []float32, acbGIn *[acbgM]float32, highBoost float32, acb []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L170-L193 + acbG := *acbGIn + if highBoost != 0.0 { + f0 := acbG[0] + 2.0*acbG[1] + f1 := acbG[0] - acbG[1] + absF2new := minF32(absF32(f1)+highBoost, absF32(f0)) + f1 = f1 * (absF2new / (absF32(f1) + 1e-12)) + acbG[0] = (f0 + 2.0*f1) / 3.0 + acbG[1] = (f0 - f1) / 3.0 + } + for i := 0; i < fcbSubfrlen; i++ { + acb[i] = acbG[0] * acbBasis[i] + } + for i := 0; i < fcbSubfrlen; i++ { + acb[i] += acbG[1] * acbBasis[fcbSubfrlen+i] + } +} + +func pitchSharp(x []float32, lag, l int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L195-L200 + for i := lag; i < l; i++ { + x[i] += x[i-lag] * pitchSharpCoef + } +} + +// synLTPBasis: build the ACB basis from the excitation history; mutates state forward. +func synLTPBasis(lags []float32, nLags int, state []float32, stateLen int, acbBasis []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L202-L269 + p := stateLen - nLags*celpLagSubfrLen + for subfr := 0; subfr < nLags; subfr++ { + iLag := int(math.Floor(float64(lags[subfr]))) + if float32(iLag) == lags[subfr] { + il := iLag + for i := 0; i < celpLagSubfrLen; i++ { + state[p+i] = state[p+i-il] + } + for i := 0; i < celpLagSubfrLen; i++ { + acbBasis[subfr*celpLagSubfrLen+i] = state[p+i] + } + for i := 0; i < celpLagSubfrLen; i++ { + acbBasis[(nLags+subfr)*celpLagSubfrLen+i] = state[p+i-il-1] + state[p+i-il+1] + } + } else { + il := iLag + baseFirst := p + (-1 - il - celpLTPInterpolDelay) + first := celpDot(state[baseFirst:], celpInterpolKernel[:], 2*celpLTPInterpolDelay) + srcBase := p + (-il - celpLTPInterpolDelay) + for nn := 0; nn < celpLagSubfrLen; nn++ { + var ret float32 + for i := 0; i < 8; i++ { + s0 := state[srcBase+nn+i] + s1 := state[srcBase+nn+15-i] + ret += (s0 + s1) * celpInterpolKernel[i] + } + state[p+nn] = ret + } + baseLast := p + (celpLagSubfrLen - il - celpLTPInterpolDelay) + last := celpDot(state[baseLast:], celpInterpolKernel[:], 2*celpLTPInterpolDelay) + for i := 0; i < celpLagSubfrLen; i++ { + acbBasis[subfr*celpLagSubfrLen+i] = state[p+i] + } + b1 := (nLags + subfr) * celpLagSubfrLen + acbBasis[b1] = first + state[p+1] + for i := 0; i < celpLagSubfrLen-2; i++ { + acbBasis[b1+1+i] = state[p+i] + state[p+i+2] + } + iLast := celpLagSubfrLen - 1 + acbBasis[b1+iLast] = state[p+iLast-1] + last + } + p += celpLagSubfrLen + } +} + +// celpDecode: add the ACB (LTP) contribution into lpcRes (voiced), then push the +// subframe into the ACB state. +func celpDecode(acbState []float32, acbStateLen int, voiced bool, acbGainIdx int32, lags []float32, numLags, subfrlen int, lowRate bool, normalizedBitrate float32, lpcRes []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L271-L306 + if voiced { + highBoost := decAcbHighBoost[0] + (decAcbHighBoost[1]-decAcbHighBoost[0])*normalizedBitrate + iLag := int(lags[numLags-1]) + if lowRate { + pitchSharp(lpcRes, iLag, subfrlen) + } + acbBasis := make([]float32, subfrlen*acbgM) + acb := make([]float32, subfrlen) + synLTPBasis(lags, numLags, acbState, acbStateLen, acbBasis) + var acbGain [acbgM]float32 + acbDequant(lowRate, acbGainIdx, &acbGain) + acbSynthesize(subfrlen, acbBasis, &acbGain, highBoost, acb) + for i := 0; i < subfrlen; i++ { + lpcRes[i] += acb[i] + } + } + // Update ACB state: shift left by subfrlen, append this subframe's excitation. + copy(acbState[0:], acbState[subfrlen:acbStateLen-subfrlen]) + copy(acbState[acbStateLen-2*subfrlen:acbStateLen-subfrlen], lpcRes[:subfrlen]) +} + +// filtAR16: y[n] = x[n] - sum_i a[16-i]*y[n-16+i]; ybuf holds a 16-sample history +// prefix at ybuf[base-16..base]. +func filtAR16(x []float32, a *[SmplOrder + 1]float32, ybuf []float32, base, n int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L308-L319 + for nn := 0; nn < n; nn++ { + res := x[nn] + for i := 0; i < SmplOrder; i++ { + res -= a[SmplOrder-i] * ybuf[base+nn-SmplOrder+i] + } + ybuf[base+nn] = res + } +} + +// CelpDecParams holds the per-subframe decoded params the synthesis consumes. +type CelpDecParams struct { + Voiced bool + SfPulses [SmplSubfrCount]int32 + FcbgIdx [SmplSubfrCount]int32 + NrgresDbqQ14 [SmplSubfrCount]int32 + AcbgIdx [SmplSubfrCount]int32 + BlockLags [2 * SmplSubfrCount]float32 // per-40-block pitch lag (codec units), 0 for unvoiced + TotalPulses int32 +} + +// CelpDecState is the persistent decoder synthesis state (C float domain). +type CelpDecState struct { + noise NoiseGenerator + acbState []float32 + acbStateLen int + lpcSynthMem [SmplOrder]float32 + lsfPrev [SmplOrder]float32 + prevNrgres float32 + hp HpPostfilterState + // traceExcPre captures the per-subframe pre-noise excitation into ExcPre (KAT only). + traceExcPre bool + ExcPre []float32 +} + +// NewCelpDecState allocates a fresh CELP decoder state. +func NewCelpDecState() *CelpDecState { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L351-L366 + acbStateLen := SmplSubfrLen + 2*celpMaxPitchLag + celpLTPInterpolDelay + return &CelpDecState{ + acbState: make([]float32, acbStateLen), + acbStateLen: acbStateLen, + hp: *NewHpPostfilterState(), + } +} + +// SynthFrame synthesizes one 20 ms internal frame (4 subframes) into 320 float +// samples in [-1, 1]. nlsf is the reconstructed order-16 NLSF; pulses are the signed +// FCB pulse magnitudes (320 positions); lowRate is the TOC bit. +func (s *CelpDecState) SynthFrame(nlsf []float32, lsfInterpolIdx int, pulses []int32, params *CelpDecParams, lowRate bool, frameLength16 int32, out []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L372-L488 + // Validation: the deterministic pre-noise excitation (FCB×gain + voiced ACB/LTP) is + // covered bit-tight by TestExcPre (exc_pre_lags.json); the noise and HP-postfilter + // stages it composes are each KAT-verified in their own modules. The full combined + // PCM output is validated end-to-end by the decoder module (e2e_vectors.json). + gains := fcbGains() + var a [SmplSubfrCount][SmplOrder + 1]float32 + var lsfs [SmplSubfrCount][SmplOrder]float32 + idx := lsfInterpolIdx + if idx > 1 { + idx = 1 + } + lpcInterpol(nlsf, &s.lsfPrev, lsfInterpol4[idx], &a, &lsfs) + + normBr := SmplGetNormalizedBitrate(params.TotalPulses, frameLength16) + + var lpcRes [SmplIntfLen]float32 + gainTab := gains.uv[:] + if params.Voiced { + gainTab = gains.v[:] + } + for pos := 0; pos < SmplIntfLen; pos++ { + if pulses[pos] != 0 { + sf := pos / SmplSubfrLen + lpcRes[pos] = float32(pulses[pos]) * gainTab[params.FcbgIdx[sf]] + } + } + + const lagsPerSubfr = 2 + var ybuf [SmplOrder + SmplIntfLen]float32 + copy(ybuf[:SmplOrder], s.lpcSynthMem[:]) + if s.traceExcPre { + s.ExcPre = s.ExcPre[:0] + } + for sf := 0; sf < SmplSubfrCount; sf++ { + base := sf * SmplSubfrLen + sfLags := []float32{params.BlockLags[2*sf], params.BlockLags[2*sf+1]} + celpDecode(s.acbState, s.acbStateLen, params.Voiced, params.AcbgIdx[sf], sfLags, lagsPerSubfr, SmplSubfrLen, lowRate, normBr, lpcRes[base:base+SmplSubfrLen]) + + if s.traceExcPre { + s.ExcPre = append(s.ExcPre, lpcRes[base:base+SmplSubfrLen]...) + } + + nrgres := SmplDecodeResnrg(params.NrgresDbqQ14[sf], int32(SmplSubfrLen)) + if !params.Voiced { + s.prevNrgres = nrgres + } + var noise [160]float32 + SmplCelpGenNoise(&s.noise, lpcRes[base:base+SmplSubfrLen], SmplSubfrLen, params.Voiced, params.SfPulses[sf], nrgres, params.FcbgIdx[sf], lsfs[sf][:], normBr, gains.uv[:], noise[:]) + for i := 0; i < SmplSubfrLen; i++ { + lpcRes[base+i] += noise[i] + } + + filtAR16(lpcRes[base:base+SmplSubfrLen], &a[sf], ybuf[:], SmplOrder+base, SmplSubfrLen) + } + copy(out[:SmplIntfLen], ybuf[SmplOrder:]) + copy(s.lpcSynthMem[:], ybuf[SmplOrder+SmplIntfLen-SmplOrder:]) + + // Post-LPC HP (pitch-harmonic) postfilter. The comb lag is the energy-weighted + // mean of the 8 per-40-block lags (0 → default fixed-corner curve, unvoiced). + var lag float32 + if params.Voiced { + var sl, sll float32 + for _, l := range params.BlockLags { + sl += l + sll += l * l + } + if sl > 0.0 { + lag = sll / sl + } + } + var hpOut [SmplIntfLen]float32 + SmplHpPostfilter(&s.hp, out[:SmplIntfLen], SmplIntfLen, lag, hpOut[:]) + copy(out[:SmplIntfLen], hpOut[:]) +} diff --git a/pkg/call/voip/media/mlow/decoder.go b/pkg/call/voip/media/mlow/decoder.go new file mode 100644 index 00000000..9d4b3a6b --- /dev/null +++ b/pkg/call/voip/media/mlow/decoder.go @@ -0,0 +1,188 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "github.com/rs/zerolog" + +// MLow top-level decoder: RED strip → TOC routing → active-frame decode (3 chained +// 20 ms internal frames: LSF → pulses → pitch/gains → reconstruct → CELP synthesis) +// → per-packet harmonic postfilter → 60 ms PCM. Cross-frame predictor and synthesis +// history persist across calls (the stream is continuous). +// +// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L1-L218 + +const opusFrameSamps = 960 // 60 ms @ 16 kHz + +// SmplDecoderState is the cross-frame decoder state: LSF predictor, previous NLSF, +// the CELP synthesis state, and the harmonic-postfilter state. +type SmplDecoderState struct { + Lstate SmplLsfState + PrevNLSF []float32 + Celp *CelpDecState + Harm *HarmPostfilterState +} + +func newSmplDecoderState() *SmplDecoderState { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L641-L672 + return &SmplDecoderState{Celp: NewCelpDecState(), Harm: NewHarmPostfilterState()} +} + +// MlowDecoder is a stateful pure-Go MLow decoder. +type MlowDecoder struct { + state *SmplDecoderState + redundancy int32 + log zerolog.Logger +} + +// NewMlowDecoder allocates a fresh decoder. +func NewMlowDecoder(opts ...Option) *MlowDecoder { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L36-L41 + return &MlowDecoder{state: newSmplDecoderState(), log: resolveConfig(opts).log} +} + +// SetRedundancy sets the negotiated RED redundancy level (0 = bare frames). +func (d *MlowDecoder) SetRedundancy(n int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L44-L46 + d.redundancy = int32(n) +} + +// Reset clears the cross-frame state (call at a stream discontinuity). +func (d *MlowDecoder) Reset() { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L49-L51 + d.state = newSmplDecoderState() +} + +// Decode decodes one RTP MLow payload into a 60 ms (960-sample) PCM frame, float in [-1, 1]. +func (d *MlowDecoder) Decode(payload []byte) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L54-L72 + if len(payload) == 0 { + d.log.Trace().Msg("decode: empty payload, emitting silence") + return make([]float32, opusFrameSamps) + } + d.log.Trace().Int("payload_bytes", len(payload)).Int32("redundancy", d.redundancy).Msg("decode packet") + if d.redundancy > 0 { + frames, err := DepackSplitRed(payload, d.log) + if err != nil { + d.log.Debug().Err(err).Int("payload_bytes", len(payload)).Msg("decode: RED depack failed, emitting silence") + return make([]float32, opusFrameSamps) + } + var main []byte + if len(frames) > 0 { + main = frames[len(frames)-1].Data // the main (current) frame is last + } + d.log.Trace().Int("red_frames", len(frames)).Int("main_bytes", len(main)).Msg("decode: RED depacked") + return d.decodeFrame(main) + } + return d.decodeFrame(payload) +} + +func (d *MlowDecoder) decodeFrame(frame []byte) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L74-L99 + if len(frame) == 0 { + d.log.Trace().Msg("decode frame: empty, emitting silence") + return make([]float32, opusFrameSamps) + } + toc := ParseSmplTOC(frame[0], d.log) + var outLen int + if toc.StdOpus { + outLen = 16000 / 1000 * toc.FrameMs + } else { + outLen = toc.SampleRate / 1000 * toc.FrameMs + } + d.log.Trace().Int("frame_bytes", len(frame)).Uint8("toc_byte", frame[0]). + Bool("std_opus", toc.StdOpus).Bool("sid", toc.SID).Bool("active", toc.Active). + Bool("voiced", toc.Voiced).Int("frame_ms", toc.FrameMs).Int("sample_rate", toc.SampleRate). + Int("out_len", outLen).Msg("decode frame") + if toc.StdOpus { + d.log.Debug().Msg("decode frame: standard-Opus packet, not handled, emitting silence") + return make([]float32, outLen) + } + if toc.SID || !toc.Active { + d.log.Trace().Bool("sid", toc.SID).Bool("active", toc.Active).Msg("decode frame: inactive/SID, emitting silence") + return make([]float32, outLen) + } + return d.decodeActiveFrame(frame, outLen) +} + +func (d *MlowDecoder) decodeActiveFrame(frame []byte, outLen int) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L101-L217 + config := int(frame[0]>>2) & 1 + tbl := LoadSmplTables() + synthT := LoadSmplSynthTables() + mem := LoadSmplMem() + dec := NewRangeDecoder(frame[1:]) + lowRate := (frame[0]>>2)&1 != 0 + + d.log.Trace().Int("config", config).Bool("low_rate", lowRate).Int("body_bytes", len(frame)-1).Int("internal_frames", 3).Msg("decode active frame") + + out := make([]float32, 0, 3*SmplIntfLen) + packetLags := make([]float32, 0, 3*8) + var avgNormBr float32 + for f := 0; f < 3; f++ { + lsf := DecodeSmplLsf(dec, tbl, &d.state.Lstate, config, f) + pulses := DecodeSmplPulses(dec, mem, SmplIntfLen, 4, 1, int32(config), lsf.Stage1) + voiced := lsf.Stage1 == 1 + var total int32 + for _, c := range pulses.Subfr { + total += c + } + params := CelpDecParams{Voiced: voiced, SfPulses: pulses.Subfr, TotalPulses: total} + if voiced { + pr := DecodeSmplPitch(dec, mem, &d.state.Lstate, SmplIntfLen, 4, int32(config), pulses.Subfr) + for b := 0; b < 8; b++ { + v := float64(pr.BlockLags[b])*0.5 + 32.0 + if v > 320.0 { + v = 320.0 + } + params.BlockLags[b] = float32(v) + } + for sf := 0; sf < 4; sf++ { + params.AcbgIdx[sf] = pr.GainIdx[sf] + if pr.FiltIdx[sf] > 0 { + params.FcbgIdx[sf] = pr.FiltIdx[sf] + } + } + } else { + g := DecodeSmplGains(dec, mem, 4, pulses.Subfr) + params.NrgresDbqQ14 = g.GainQ + params.FcbgIdx = g.NrgRes + } + packetLags = append(packetLags, params.BlockLags[:]...) + avgNormBr += SmplGetNormalizedBitrate(params.TotalPulses, SmplIntfLen) + + d.log.Trace().Int("intf", f).Bool("voiced", voiced).Int32("stage1", lsf.Stage1). + Int32("grid", lsf.Grid).Int32("total_pulses", total).Int("nlsf_len", len(d.state.PrevNLSF)). + Msg("decode internal frame params") + + nlsf := SmplReconstructNLSF(synthT, int(lsf.Stage1), config, int(lsf.Grid), &lsf.Stage2, d.state.PrevNLSF) + var sig [SmplIntfLen]float32 + d.state.Celp.SynthFrame(nlsf, int(lsf.Extra), pulses.Pulses, ¶ms, lowRate, SmplIntfLen, sig[:]) + d.state.PrevNLSF = nlsf + out = append(out, sig[:]...) + } + + // Per-packet harmonic postfilter (final pitch comb + 48-sample group delay) over the whole packet. + plen := len(out) + d.log.Trace().Int("samples", plen).Int("packet_lags", len(packetLags)).Msg("decode active frame: applying harmonic postfilter") + SmplHarmPostfilter(d.state.Harm, out, plen, packetLags, len(packetLags), avgNormBr/3.0) + + pcm := make([]float32, len(out)) + for i, v := range out { + switch { + case v > 1.0: + v = 1.0 + case v < -1.0: + v = -1.0 + } + pcm[i] = v + } + if outLen > 0 && outLen != len(pcm) { + if outLen <= len(pcm) { + pcm = pcm[:outLen] + } else { + np := make([]float32, outLen) + copy(np, pcm) + pcm = np + } + } + return pcm +} diff --git a/pkg/call/voip/media/mlow/encoder.go b/pkg/call/voip/media/mlow/encoder.go new file mode 100644 index 00000000..a81e34f9 --- /dev/null +++ b/pkg/call/voip/media/mlow/encoder.go @@ -0,0 +1,647 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "errors" + "math" + + "github.com/rs/zerolog" +) + +// MLow ENCODER (module #16, outbound counterpart of mlow/decoder). +// +// This file holds the voiced/unvoiced classifier (smpl_signal_mode.rs) and the +// entropy coder (EncodeSmplFrame — the exact inverse of the byte-exact decoder). +// The classifier folds five voicing strengths (pitch correlation, VAD, spectral +// tilt, harmonicity, short lag) plus a per-stream hysteresis into a single +// voicing_strength; the encoder codes a frame voiced when that is positive and the +// packet is coded-as-active. The full PCM→wire path (MlowEncoder.Encode) drives the +// analysis front-end (analysis.go: LPC, perc, pitch, CELP, bitrate) → EncodeSmplFrame +// and round-trips a tone through the decoder (TestEncodeRoundTripsATone, corr 0.89). +// +// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L1-L222 + +// SmplEncodeBufBytes is the range-encoder body capacity (mirrors SMPL_ENCODE_BUF_BYTES). +const SmplEncodeBufBytes = 512 + +// smpl_vuv_weights (smpl_tables.c): weights on corrs, vad, tilt, harmonicity, +// short lags. The C declares 6 but sums only the first 5. +var smplVuvWeights = [5]float32{1.0, 0.5, 0.5, 0.7, 0.3} + +const ( + smplVuvBias float32 = -0.1038 + smplVuvHyst float32 = 0.05 + transitionIx = SmplFLen / 3 // low/high spectral-tilt band split + harmonicityUndef float32 = -10000.0 + numHarms = 4 +) + +// smplInvSigmoid is the C smpl_inv_sigmoid: -ln(1/x - 1). +func smplInvSigmoid(x float32) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L30-L33 + return -float32(math.Log(float64(1.0/x - 1.0))) +} + +// vuvDot is smpl_dot_prod over the first l elements (float32 accumulation, to +// match the reference's f32 rounding). +func vuvDot(a, b []float32, l int) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L35-L42 + var s float32 + for i := 0; i < l; i++ { + s += a[i] * b[i] + } + return s +} + +// vuvSum is smpl_sum_vec over the first l elements. +func vuvSum(x []float32, l int) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L44-L51 + var s float32 + for i := 0; i < l && i < len(x); i++ { + s += x[i] + } + return s +} + +// VuvMode is the per-stream voicing hysteresis + spectral-tilt background tracker +// (VUV_Mode in the C). The encoder threads one instance across the whole stream; +// the zero value matches the C calloc init. +type VuvMode struct { + nrgLoBgn float32 + nrgHiBgn float32 + voicingPrev float32 + lastLagPrev float32 +} + +// spectralHarmonicity (smpl_pitch_util.c): harmonic peak/valley energy ratio at +// low frequencies, from the per-bin weighted power spectrum f2w. cache is the C's +// per-call harmonicity memo keyed by harmonic bin; reset clears it. +func spectralHarmonicity(avgLag float32, f2w []float32, cache []float32, reset bool) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L78-L99 + if reset { + for i := range cache { + cache[i] = harmonicityUndef + } + } + invF2StepHz := 2.0 * float32(SmplFLen-1) / 16000.0 + harmHz := 16000.0 / avgLag + harmIx := int32(math.Round(float64(harmHz * 2.0 * invF2StepHz))) + cacheLen := int32(len(cache)) + if harmIx >= cacheLen { + // The C asserts this never happens; guard defensively and recompute. + return recomputeHarmonicity(harmHz, invF2StepHz, f2w) + } + if cache[harmIx] > harmonicityUndef { + return cache[harmIx] + } + hs := recomputeHarmonicity(harmHz, invF2StepHz, f2w) + cache[harmIx] = hs + return hs +} + +func recomputeHarmonicity(harmHz, invF2StepHz float32, f2w []float32) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L103-L147 + harmWidth := harmHz * invF2StepHz + harmStrength := float32(0.1) + if harmWidth > 1.97 { + var peakValleyMags [2*numHarms + 1]float32 + invHarmWidth := 1.0 / harmWidth + for numHarm := 0; numHarm < len(peakValleyMags); numHarm++ { + ixStart := 0.5 * float32(numHarm) * harmWidth + ixEnd := ixStart + harmWidth + idxStart := int32(math.Ceil(float64(ixStart))) + idxEnd := int32(math.Floor(float64(ixEnd))) + weightsLen := int(idxEnd - idxStart + 1) + if weightsLen < 0 { + weightsLen = 0 + } + var weights [20]float32 + for i := 0; i < weightsLen && i < len(weights); i++ { + tmp := (float32(idxStart) - ixStart + float32(i)) * invHarmWidth + tmp -= tmp * tmp + weights[i] = tmp * tmp + } + base := int(idxStart) + if base < 0 { + base = 0 + } + if base > len(f2w) { + base = len(f2w) + } + avail := len(f2w) - base + if avail > weightsLen { + avail = weightsLen + } + peakValleyNrg := vuvDot(f2w[base:], weights[:], avail) / vuvSum(weights[:], weightsLen) + peakValleyMags[numHarm] = float32(math.Sqrt(float64(peakValleyNrg + 1e-30))) + } + var magRatiosLog [numHarms]float32 + var magWeights [numHarms]float32 + magPeakW := [3]float32{1.0, 10.0, 1.0} + magValleyW := [3]float32{5.0, 2.0, 5.0} + for numHarm := 0; numHarm < numHarms; numHarm++ { + magPeak := magPeakW[0]*peakValleyMags[2*numHarm] + + magPeakW[1]*peakValleyMags[2*numHarm+1] + + magPeakW[2]*peakValleyMags[2*numHarm+2] + magValley := magValleyW[0]*peakValleyMags[2*numHarm] + + magValleyW[1]*peakValleyMags[2*numHarm+1] + + magValleyW[2]*peakValleyMags[2*numHarm+2] + magRatiosLog[numHarm] = float32(math.Log(float64(magPeak / magValley))) + magWeights[numHarm] = float32(math.Sqrt(float64(magPeak + magValley + 1e-30))) + } + harmStrength = vuvDot(magWeights[:], magRatiosLog[:], numHarms) / vuvSum(magWeights[:], numHarms) + } + return harmStrength +} + +// BuildF2w builds the C F2w (F2[i] * (i+3), with F2w[0]=F2w[1]=0). +func BuildF2w(f2 *[SmplFLen]float32) [SmplFLen]float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L150-L156 + var f2w [SmplFLen]float32 + for i := 2; i < SmplFLen; i++ { + f2w[i] = f2[i] * float32(i+3) + } + return f2w +} + +// HarmStrengthAt is the harmonicity at avgLag with a fresh cache (the C call +// right after the pitch search). Reused by the pitch estimator so its +// harm_strength matches the value fed to SmplGetSignalMode. +func HarmStrengthAt(avgLag float32, f2w *[SmplFLen]float32) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L160-L163 + var cache [50]float32 + return spectralHarmonicity(avgLag, f2w[:], cache[:], true) +} + +// SmplGetSignalMode combines the five voicing strengths + hysteresis into the +// voicing strength; it mutates vuv. lags is the per-lag-subframe pitch lag in +// samples; f2 is the power spectrum F2[0..256]. +func SmplGetSignalMode( + pitchcorr float32, + lags []float32, + avgLag float32, + harmStrength float32, + f2 *[SmplFLen]float32, + spActProb float32, + vuv *VuvMode, +) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L168-L222 + pc := pitchcorr + if pc < 0.0 { + pc = 0.0 + } else if pc > 1.0 { + pc = 1.0 + } + corrStrength := smplInvSigmoid(0.1 + 0.75*pc) // -1.4 .. 1.4 + vadStrength := 0.04 * (1.0 - 1.04/(spActProb+0.04)) // -1 .. 0 + + // spectral tilt + var nrgLo float32 + for i := 2; i < transitionIx; i++ { + tmp := f2[i] * float32(i+3) + nrgLo += tmp * float32(transitionIx-i) + } + var nrgHi float32 + for i := transitionIx; i < SmplFLen; i++ { + tmp := f2[i] * float32(i+3) + nrgHi += tmp * float32(i-transitionIx) + } + if vadStrength < -0.1 { + smthCoef := -0.5 * vadStrength + vuv.nrgLoBgn += smthCoef * (nrgLo - vuv.nrgLoBgn) + vuv.nrgHiBgn += smthCoef * (nrgHi - vuv.nrgHiBgn) + } + loDiff := nrgLo - vuv.nrgLoBgn + if loDiff < 0.0 { + loDiff = 0.0 + } + hiDiff := nrgHi - vuv.nrgHiBgn + if hiDiff < 0.0 { + hiDiff = 0.0 + } + tiltLin := (loDiff - hiDiff) / (nrgLo + nrgHi + 1e-9) + tiltStrength := tiltLin * tiltLin * tiltLin // make less binary + lagStrength := -smplSigmoid(0.25 * (38.0 - avgLag)) + + voicingStrength := (smplVuvWeights[0]*corrStrength+ + smplVuvWeights[1]*vadStrength+ + smplVuvWeights[2]*tiltStrength+ + smplVuvWeights[3]*harmStrength+ + smplVuvWeights[4]*lagStrength)/ + vuvSum(smplVuvWeights[:], 5) + smplVuvBias + + // hysteresis + if vuv.lastLagPrev > 0.0 { + tmp := float32(math.Log2(float64(lags[0] / vuv.lastLagPrev))) + if tmp > 0.0 { + tmp *= 0.5 + } + vuv.voicingPrev /= 0.4 + tmp*tmp + } + voicingStrength += vuv.voicingPrev * smplVuvHyst + vuv.voicingPrev = float32(math.Tanh(float64(3.0 * voicingStrength))) + vuv.lastLagPrev = lags[len(lags)-1] + + return voicingStrength +} + +// --- entropy encoder (the exact inverse of the byte-exact decoder) ---------- + +// ErrEncodeUnimplemented marks the parts of the encode path that are not yet built. +var ErrEncodeUnimplemented = errors.New("mlow encode: analysis front-end (pcm→params) not yet implemented") + +// SmplRawSym is one uniform raw-symbol write (encode(sym, sym+1, 1<> 1 + return (a - b) & 0xffff + } + ft := triT(l) + if ft == 0 { + ft = 1 + } + var fl uint32 + if total > 0 { + fl = triT(uint32(total - 1)) + } + fh := triT(uint32(total)) + enc.Encode(fl, fh, ft) + if total == 0 { + return + } + + // --- recursive binary SPLIT --- + finalSum := pp.Subfr[0] + pp.Subfr[1] + initSum := total - subfrLen16*2 + if initSum < 0 { + initSum = 0 + } + lo := total - 80 + if lo < 0 { + lo = 0 + } + if initSum < lo { + return + } + hiBound := total - lo + if initSum < hiBound { + cdf := cdfWindow(cc.SplitCmf(total), int(initSum-lo), int((hiBound-initSum)+2)) + enc.EncodeCDF(finalSum-initSum, cdf) + } + if finalSum > 0 { + encodeSplit3537(enc, cc, finalSum, subfrLen16, pp.Subfr[0]) + } + if finalSum < total { + encodeSplit3537(enc, cc, total-finalSum, subfrLen16, pp.Subfr[2]) + } + + // --- MAGNITUDE block: replay recorded run-length symbols through the same loop --- + posPer := p2 / p3 + magIdx := 0 + for subfr := int32(0); subfr < p3; subfr++ { + cnt := pp.Subfr[subfr] + if cnt <= 0 { + continue + } + pos := posPer + c := cnt + k := int32(0) + for k < cnt { + oct := (pos + 7) / 8 + bucket := cc.Runlen(oct) + start := int(bucket.MaxSamples() - pos) + m := pp.MagRuns[magIdx] + magIdx++ + enc.EncodeCDF(m, cdfWindow(bucket.Cmf(c), start, int(pos+1))) + if m > 0 || k == 0 { + pos -= m + } + c-- + k++ + } + } + + // --- SIGN block: replay recorded raw sign symbols --- + for _, rs := range pp.SignSyms { + enc.EncodeRawSymbol(rs.Sym, rs.Nbits) + } +} + +// encodeSplit3537 is the inverse of smplSplit3537: encode the first-half count s0. +func encodeSplit3537(enc *RangeEncoder, cc *CcTables, count, granularity int32, s0 int32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L273-L292 + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/encode.rs#L273-L292 (seed cc-table rewire: SplitCmf) + lo := count + if granularity < lo { + lo = granularity + } + minSplit := count - granularity + if minSplit < 0 { + minSplit = 0 + } + if lo < minSplit || minSplit == lo { + return + } + cdf := cdfWindow(cc.SplitCmf(count), int(minSplit), int((lo-minSplit)+2)) + enc.EncodeCDF(s0-minSplit, cdf) +} + +// encodeSmplGains is the inverse of DecodeSmplGains: encode main/delta gain, then +// per-subframe nrgres with the same gain-derived address shift. +func encodeSmplGains(enc *RangeEncoder, _ *SmplMem, p3 int32, subfrCounts [4]int32, gp *SmplGainParams) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L294-L335 + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/encode.rs#L294-L335 (seed cc-table rewire: Group A/E from CcTables) + cc := LoadCcTables() + enc.EncodeCDF(gp.GainMain, cc.NrgresGain4()) + enc.EncodeCDF(gp.GainDelta, cc.NrgresShape4()) + cfgSel := int32(2) + + off6 := p3 * gp.GainDelta + base7 := gp.GainMain*cc.NrgStep(cfgSel) - 0x154000 + var gainQ [4]int32 + take := int(p3) + if take > 4 { + take = 4 + } + for sf := 0; sf < take; sf++ { + cbv := cc.GainRecon(p3 == 4, int32(sf)+off6) + gainQ[sf] = base7 + (cbv << 4) + } + + for sf := 0; sf < take; sf++ { + cnt := subfrCounts[sf] + if cnt <= 0 { + continue + } + var bucket int32 + if cnt >= 30 { + bucket = 3 + } else { + bucket = (cnt & 0xffff) / 10 + } + g := (gainQ[sf] + 8192) >> 14 + if g < -85 { + g = -85 + } + negPart := (g >> 31) & g + minOffset := int(-negPart) + enc.EncodeCDF(gp.NrgRes[sf], cc.FcbgOffset(int(cfgSel), int(bucket), minOffset)) + } +} + +// encodeSmplPitch is the inverse of DecodeSmplPitch: encode the LTP gains/filters, +// then the lag contour (blockseg selector + per-block lag indices) via the pitch +// tables, mutating the predictor state identically. +func encodeSmplPitch(enc *RangeEncoder, _ *SmplMem, st *SmplLsfState, p2, p3, p6 int32, subfrCounts [4]int32, pp *SmplPitchParams) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L337-L405 + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/encode.rs#L337-L405 (seed cc-table rewire: Group C LTP gains from CcTables) + cc := LoadCcTables() + + var gainAccum int32 + take := int(p3) + if take > 4 { + take = 4 + } + for sf := 0; sf < take; sf++ { + cnt := subfrCounts[sf] + gi := pp.GainIdx[sf] + if p6 != 0 { + enc.EncodeCDF(gi, cc.AcbgainRowLr(st.PrevGainIdx)) + } else { + enc.EncodeCDF(gi, cc.AcbgainRow(st.PrevGainIdx)) + } + st.PrevGainIdx = gi + var w0, w2 int32 + if p6 != 0 { + w0, w2 = cc.AcbgainWeightsLr(gi) + } else { + w0, w2 = cc.AcbgainWeights(gi) + } + gainAccum += w0 + 2*w2 + if cnt > 0 { + fi := pp.FiltIdx[sf] + if st.PrevFiltIdx == -1 { + enc.EncodeCDF(fi, cc.FcbgainV()) + } else { + enc.EncodeCDF(fi, cc.FcbgainVDelta(st.PrevFiltIdx)) + } + st.PrevFiltIdx = fi + } + } + avgGain := gainAccum / p3 + + mode := 0 + if avgGain >= 10007 { + if avgGain < 14085 { + mode = 1 + } else { + mode = 2 + } + } + tab := LoadPitchTables() + encodeLagsWire(tab, enc, pp.BlocksegIdx, &pp.Laginds, st.PrevLagblk, st.PrevLagidx, mode) + nblk, nidx := smplLagsPredictorAfter(tab, pp.BlocksegIdx, &pp.Laginds) + st.PrevLagblk = nblk + st.PrevLagidx = nidx +} + +// EncodeSmplFrame builds [TOC || range-coded body] from analyzed frame parameters +// (the exact inverse of the decoder's active-frame body decode). +func EncodeSmplFrame(fp *SmplFrameParams, log ...zerolog.Logger) ([]byte, error) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L61-L102 + lg := pickLog(log) + const p2, p3, p4 = int32(320), int32(4), int32(1) + p6 := int32(fp.Config) + lg.Trace().Uint8("toc_byte", fp.TOC).Int("config", fp.Config).Int("internal_frames", 3).Msg("encode frame") + tbl := LoadSmplTables() + mem := LoadSmplMem() + enc := NewRangeEncoder(1 + SmplEncodeBufBytes) + var st SmplLsfState + for f := 0; f < 3; f++ { + ip := &fp.Internal[f] + lg.Trace().Int("intf", f).Bool("voiced", ip.Lsf.Stage1 == 1).Int32("stage1", ip.Lsf.Stage1). + Int32("total_pulses", ip.Pulses.Total).Bool("has_pitch", ip.HasPitch).Msg("encode internal frame params") + encodeSmplLsf(enc, tbl, &st, fp.Config, f, &ip.Lsf) + encodeSmplPulses(enc, mem, p2, p3, p4, p6, ip.Lsf.Stage1, &ip.Pulses) + if ip.Lsf.Stage1 == 1 { + encodeSmplPitch(enc, mem, &st, p2, p3, p6, ip.Pulses.Subfr, &ip.Pitch) + } else { + encodeSmplGains(enc, mem, p3, ip.Pulses.Subfr, &ip.Gains) + } + } + enc.Done() + if enc.Err() != 0 { + lg.Debug().Int32("err", enc.Err()).Msg("encode frame: range-encoder buffer overflow") + return nil, errors.New("mlow encode: range-encoder buffer overflow") + } + n := enc.ConsumedLen() + body := enc.Bytes() + out := make([]byte, 0, 1+n) + out = append(out, fp.TOC) + out = append(out, body[:n]...) + lg.Trace().Int("frame_bytes", len(out)).Int("body_bytes", n).Msg("encode frame: done") + return out, nil +} + +// MlowEncoder is the stateful top-level MLow encoder. The cross-frame analysis +// history (SmplEncoderState, in analysis.go) persists across Encode calls. +type MlowEncoder struct { + state SmplEncoderState + log zerolog.Logger +} + +// NewMlowEncoder allocates a fresh encoder. +func NewMlowEncoder(opts ...Option) *MlowEncoder { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L33-L37 + return &MlowEncoder{log: resolveConfig(opts).log} +} + +// Reset clears the cross-frame analysis history (call at a stream discontinuity). +func (e *MlowEncoder) Reset() { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L40-L43 + e.state = SmplEncoderState{} +} + +// Encode turns one 60 ms frame (exactly 960 samples) into a wire MLow frame: +// sanitize (NaN→0, clamp [-1,1]) → analysis (PCM → SmplFrameParams) → entropy code. +func (e *MlowEncoder) Encode(pcm []float32) ([]byte, error) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L46-L57 + if len(pcm) != opusFrameSamps { + e.log.Debug().Int("samples", len(pcm)).Int("want", opusFrameSamps).Msg("encode: wrong frame size") + return nil, errors.New("mlow encode: expected 960 samples (60 ms @16 kHz)") + } + e.log.Trace().Int("samples", len(pcm)).Msg("encode frame: sanitizing and analyzing") + clean := make([]float32, len(pcm)) + for i, s := range pcm { + switch { + case math.IsNaN(float64(s)): + s = 0.0 + case s < -1.0: + s = -1.0 + case s > 1.0: + s = 1.0 + } + clean[i] = s + } + fp := smplAnalyzeFrameSt(&e.state, clean) + return EncodeSmplFrame(&fp, e.log) +} diff --git a/pkg/call/voip/media/mlow/fft.go b/pkg/call/voip/media/mlow/fft.go new file mode 100644 index 00000000..83e82e15 --- /dev/null +++ b/pkg/call/voip/media/mlow/fft.go @@ -0,0 +1,104 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "math" + +// cpx is a single-precision complex value. +// +// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L318-L343 +type cpx struct { + re, im float32 +} + +func (a cpx) add(b cpx) cpx { + return cpx{re: a.re + b.re, im: a.im + b.im} +} + +func (a cpx) mul(b cpx) cpx { + return cpx{ + re: a.re*b.re - a.im*b.im, + im: a.re*b.im + a.im*b.re, + } +} + +// smallestFactor returns the smallest prime factor of n (>= 2). +func smallestFactor(n int) int { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L346-L358 + if n%2 == 0 { + return 2 + } + p := 3 + for p*p <= n { + if n%p == 0 { + return p + } + p += 2 + } + return n +} + +// fftRec is the recursive mixed-radix Cooley-Tukey DFT. sign is -1 forward, +1 +// inverse (unnormalized). x holds n inputs at the given stride; out is contiguous. +func fftRec(x []cpx, stride, n int, sign float32, out []cpx) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L362-L405 + if n == 1 { + out[0] = x[0] + return + } + p := smallestFactor(n) + if p == n { + for k := 0; k < n; k++ { + var acc cpx + angK := sign * 2.0 * smplPI * float32(k) / float32(n) + for j := 0; j < n; j++ { + ang := angK * float32(j) + w := cpx{re: float32(math.Cos(float64(ang))), im: float32(math.Sin(float64(ang)))} + acc = acc.add(x[j*stride].mul(w)) + } + out[k] = acc + } + return + } + m := n / p + sub := make([]cpx, n) + for q := 0; q < p; q++ { + fftRec(x[q*stride:], stride*p, m, sign, sub[q*m:(q+1)*m]) + } + for k := 0; k < n; k++ { + kmod := k % m + var acc cpx + for q := 0; q < p; q++ { + ang := sign * 2.0 * smplPI * float32(k) * float32(q) / float32(n) + tw := cpx{re: float32(math.Cos(float64(ang))), im: float32(math.Sin(float64(ang)))} + acc = acc.add(sub[q*m+kmod].mul(tw)) + } + out[k] = acc + } +} + +// cfft computes the complex FFT of a mixed-radix length into out. sign=-1 forward, +// +1 inverse. +func cfft(input, out []cpx, sign float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L408-L412 + fftRec(input, 1, len(input), sign, out) +} + +// rfftForwardOrdered is the forward real FFT of n real samples, re-packed into the +// ordered REAL layout: f[0]=DC.re, f[1]=Nyquist.re, then [re,im] pairs for bins +// 1..n/2-1. Output length is n. +func rfftForwardOrdered(time, f []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L416-L432 + n := len(time) + cin := make([]cpx, n) + for i := 0; i < n; i++ { + cin[i].re = time[i] + } + spec := make([]cpx, n) + cfft(cin, spec, -1.0) + f[0] = spec[0].re + f[1] = spec[n/2].re + for i := 1; i < n/2; i++ { + f[2*i] = spec[i].re + f[2*i+1] = spec[i].im + } +} diff --git a/pkg/call/voip/media/mlow/gains.go b/pkg/call/voip/media/mlow/gains.go new file mode 100644 index 00000000..09fb81f7 --- /dev/null +++ b/pkg/call/voip/media/mlow/gains.go @@ -0,0 +1,66 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +// Per-subframe gains + energy-residual decode (func 3545 GAINS block), for UNVOICED +// internal frames (LSF stage-1 selector 0) — mutually exclusive with the pitch block. + +// SmplGainResult holds the decoded per-subframe gains and energy-residual symbols. +type SmplGainResult struct { + GainQ [4]int32 // per-subframe quantized log-gain (Q-domain) + NrgRes [4]int32 // per-subframe energy-residual symbol (only subframes with pulses are read) + // Raw entropy symbols (for the encoder to replay): the main + delta gain symbols. + GainMain int32 + GainDelta int32 +} + +// DecodeSmplGains decodes the gains+nrgres reads (the p3==4 path). subfrCounts are +// the per-subframe pulse counts. Group A/E tables come from the seed-built CcTables +// (the mem param is retained for call-site stability; only pitch lag reads use it). +func DecodeSmplGains(dec *RangeDecoder, _ *SmplMem, p3 int32, subfrCounts [4]int32) SmplGainResult { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gains.rs#L18-L69 + var res SmplGainResult + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_gains.rs#L29-L67 (seed cc-table rewire: Group A/E from CcTables) + cc := LoadCcTables() + + // main gain (n=85) + delta gain (n=99) + gainMain := dec.DecodeCDF(cc.NrgresGain4()) + gainDelta := dec.DecodeCDF(cc.NrgresShape4()) + res.GainMain = gainMain + res.GainDelta = gainDelta + cfgSel := int32(2) + + // gain reconstruction: base7 = gain_main*nrg_step - 0x154000; cbv = gain_recon[sf + p3*delta]. + off6 := p3 * gainDelta + base7 := gainMain*cc.NrgStep(cfgSel) - 0x154000 + take := int(p3) + if take > 4 { + take = 4 + } + for sf := 0; sf < take; sf++ { + cbv := cc.GainRecon(p3 == 4, int32(sf)+off6) + res.GainQ[sf] = base7 + (cbv << 4) + } + + // nrgres: per-subframe bucketed CDF (n=92) sliced by the gain-derived offset. + for sf := 0; sf < take; sf++ { + cnt := subfrCounts[sf] + if cnt <= 0 { + continue + } + var bucket int32 + if cnt >= 30 { + bucket = 3 + } else { + bucket = (cnt & 0xffff) / 10 + } + // g = clamp((gainQ[sf]+8192)>>14, floor -85); min_offset = -neg_part (forward entry shift). + g := (res.GainQ[sf] + 8192) >> 14 + if g < -85 { + g = -85 + } + negPart := (g >> 31) & g + minOffset := int(-negPart) + res.NrgRes[sf] = dec.DecodeCDF(cc.FcbgOffset(int(cfgSel), int(bucket), minOffset)) + } + return res +} diff --git a/pkg/call/voip/media/mlow/logging.go b/pkg/call/voip/media/mlow/logging.go new file mode 100644 index 00000000..f15a53da --- /dev/null +++ b/pkg/call/voip/media/mlow/logging.go @@ -0,0 +1,36 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "github.com/rs/zerolog" + +// Option configures optional, non-behavioral aspects of the codec — currently the +// diagnostic logger. The zero configuration logs nothing. +type Option func(*config) + +type config struct { + log zerolog.Logger +} + +func resolveConfig(opts []Option) config { + c := config{log: zerolog.Nop()} + for _, opt := range opts { + opt(&c) + } + return c +} + +// WithLogger sets the zerolog logger for debug/trace diagnostics. The library never +// configures logging itself; without this option the codec is silent at zero cost. +// Pass the logger from a context, e.g. WithLogger(*zerolog.Ctx(ctx)). +func WithLogger(l zerolog.Logger) Option { + return func(c *config) { c.log = l } +} + +// pickLog resolves the optional trailing logger of a stateless codec function: the +// first supplied logger, or a silent Nop logger when none was passed. +func pickLog(log []zerolog.Logger) zerolog.Logger { + if len(log) > 0 { + return log[0] + } + return zerolog.Nop() +} diff --git a/pkg/call/voip/media/mlow/lpc.go b/pkg/call/voip/media/mlow/lpc.go new file mode 100644 index 00000000..66f74046 --- /dev/null +++ b/pkg/call/voip/media/mlow/lpc.go @@ -0,0 +1,585 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "math" + +const ( + SmplLPCOrder = 16 + SmplLPCBufLen = 448 + SmplLPCNFFT = 512 + SmplFLen = SmplLPCNFFT/2 + 1 +) + +// smplPI is the truncated literal the reference uses (not math.Pi) — load-bearing +// for bit-faithful window/NLSF math. +// +// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L25 +const smplPI = 3.1415926535897 + +const ( + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L411-L414 + lsfCosTabSzFix = 128 + binDivStepsA2NLSFFix = 3 + maxIterationsA2NLSFFix = 16 + silkInt16Max = 32767 +) + +const ( + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L26-L32 + // + // smplPIF64 mirrors the reference's `SMPL_PI as f64`: the f32 literal widened, + // not full-precision pi. + smplPIF64 = float64(float32(3.1415926535897)) + smplLPCReg = 5e-7 + smplLPCBwe = 0.9999 + smplLPCWin120msLen = 264 + smplWin3LongLen = 64 + smplWin3ShortLen = 32 + + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L94 + nfft4 = SmplLPCNFFT / 4 // 128 +) + +const ( + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L291-L292 + smplSubfrs = 4 + maxRCStable float32 = 0.9995 +) + +// smplLSFInterpol4Tbl holds the per-subframe interpolation weight rows (idx 0 and +// the alternative idx 1). +// +// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L289-L290 +var smplLSFInterpol4Tbl = [2][smplSubfrs]float32{ + {0.55, 0.88, 1.0, 1.0}, + {0.3, 0.65, 0.95, 1.0}, +} + +func genSinWin(n int) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L39-L43 + w := make([]float32, n) + for i := 0; i < n; i++ { + t := (float32(i) + 1.0) / (float32(n) + 1.0) * smplPI / 2.0 + w[i] = float32(math.Sin(float64(t))) + } + return w +} + +func genCosWin(n int) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L46-L50 + w := make([]float32, n) + for i := 0; i < n; i++ { + t := (float32(i) + 1.0) / (float32(n) + 1.0) * smplPI / 2.0 + w[i] = float32(math.Cos(float64(t))) + } + return w +} + +// smplWindowLPC20 applies the 20 ms LPC analysis window to a raw analysis buffer, +// producing the windowed buffer the autocorrelation FFT consumes. useLongWin +// selects the 64-tap vs 32-tap trailing cosine taper. +func smplWindowLPC20(input *[SmplLPCBufLen]float32, useLongWin bool) [SmplLPCBufLen]float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L55-L90 + win1 := genSinWin(smplLPCWin120msLen) + var win3 []float32 + var win3len int + if useLongWin { + win3, win3len = genCosWin(smplWin3LongLen), smplWin3LongLen + } else { + win3, win3len = genCosWin(smplWin3ShortLen), smplWin3ShortLen + } + var out [SmplLPCBufLen]float32 + for i := 0; i < smplLPCWin120msLen; i++ { + out[i] = input[i] * win1[i] + } + mid := SmplLPCBufLen - smplLPCWin120msLen - smplWin3LongLen + copy(out[smplLPCWin120msLen:smplLPCWin120msLen+mid], input[smplLPCWin120msLen:smplLPCWin120msLen+mid]) + base := SmplLPCBufLen - smplWin3LongLen + for i := 0; i < win3len; i++ { + out[base+i] = input[base+i] * win3[i] + } + if !useLongWin { + for s := base + smplWin3ShortLen; s < base+smplWin3LongLen; s++ { + out[s] = 0.0 + } + } + return out +} + +// genCosRow accumulates row[k] = cos(omega)*scale, advancing omega by a running +// fmod in f64 (matching the reference, not cos(k*domega)). +func genCosRow(domega, scale float64) [nfft4]float64 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L98-L107 + var row [nfft4]float64 + omega := 0.0 + twoPi := 2.0 * smplPIF64 + for k := 0; k < nfft4; k++ { + row[k] = math.Cos(omega) * scale + omega = math.Mod(omega+domega, twoPi) + if omega < 0 { + omega += twoPi + } + } + return row +} + +type dctTables struct { + cdif [SmplLPCOrder / 2][nfft4]float64 + csumdiff [SmplLPCOrder / 4][nfft4]float64 + csumsum [SmplLPCOrder / 4][nfft4]float64 +} + +func buildDctTables() dctTables { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L115-L143 + twoPi := 2.0 * smplPIF64 + nfft := float64(SmplLPCNFFT) + var t dctTables + for j := 0; j < SmplLPCOrder/2; j++ { + t.cdif[j] = genCosRow(float64(1+j*2)*twoPi/nfft, 2.0/nfft) + } + for j := 0; j < SmplLPCOrder/4; j++ { + t.csumdiff[j] = genCosRow(float64(2+j*4)*twoPi/nfft, 1.0/nfft) + } + for j := 0; j < SmplLPCOrder/4; j++ { + t.csumsum[j] = genCosRow(float64(4+j*4)*twoPi/nfft, 1.0/nfft) + } + return t +} + +// bruteDct derives the autocorrelation R[0..order] from the power spectrum via the +// precomputed cosine sums. All accumulation in f64. +func bruteDct(t *dctTables, f2 []float64, order int, r []float64) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L147-L186 + half := SmplLPCNFFT / 2 + f2sum := 0.0 + var f2dif, f2sumsum, f2sumdif [nfft4]float64 + for n := 0; n < nfft4; n++ { + f2sum += f2[n] + f2[nfft4+n] + f2dif[n] = f2[n] - f2[half-n] + f2sumsum[n] = f2[n] + f2[half-n] + f2[nfft4+n] + f2[nfft4-n] + f2sumdif[n] = f2[n] + f2[half-n] - f2[nfft4+n] - f2[nfft4-n] + } + f2dif[0] *= 0.5 + r[0] = (2.0*f2sum - f2[0] + f2[half]) / float64(SmplLPCNFFT) + for j := 0; j < order/2; j++ { + rtmp := 0.0 + row := &t.cdif[j] + for k := 0; k < nfft4; k++ { + rtmp += row[k] * f2dif[k] + } + r[1+j*2] = rtmp + } + for j := 0; j < order/4; j++ { + rtmp := 0.0 + row := &t.csumdiff[j] + for k := 0; k < nfft4; k++ { + rtmp += row[k] * f2sumdif[k] + } + r[2+j*4] = rtmp + } + for j := 0; j < order/4; j++ { + rtmp := 0.0 + row := &t.csumsum[j] + for k := 0; k < nfft4; k++ { + rtmp += row[k] * f2sumsum[k] + } + r[4+j*4] = rtmp + } +} + +// ac2rcDbl converts autocorrelation R[0..order] to reflection coefficients (Schur), +// with C0[0] *= (1+reg). Each rc[k] is truncated to f32, matching the reference. +func ac2rcDbl(corr []float64, order int, reg float32, rc []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L190-L220 + c0 := make([]float64, order+1) + c1 := make([]float64, order+1) + copy(c0, corr[:order+1]) + c0[0] *= float64(1.0 + reg) + copy(c1, c0[:order+1]) + for i := 0; i < order; i++ { + rc[i] = 0 + } + for k := 0; k < order; k++ { + if c0[k+1] > c1[0] { + rc[k] = -1.0 + break + } + if c0[k+1] < -c1[0] { + rc[k] = 1.0 + break + } + if c1[0] == 0.0 { + break + } + rcTmp := -c0[k+1] / c1[0] + rc[k] = float32(rcTmp) + for n := 0; n < order-k; n++ { + ctmp1 := c0[n+k+1] + ctmp2 := c1[n] + c0[n+k+1] = ctmp1 + ctmp2*rcTmp + c1[n] = ctmp2 + ctmp1*rcTmp + } + } +} + +// rc2a converts reflection coefficients to monic LPC A[0..order] (A[0]=1). +func rc2a(rc []float32, order int, a []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L223-L238 + for i := 1; i < order+1; i++ { + a[i] = 0 + } + a[0] = 1.0 + for k := 0; k < order; k++ { + rcTmp := rc[k] + for n := 0; n < (k+1)/2; n++ { + tmp1 := a[n+1] + tmp2 := a[k-n] + a[n+1] = tmp1 + tmp2*rcTmp + a[k-n] = tmp2 + tmp1*rcTmp + } + a[k+1] = rcTmp + } +} + +// bweExpand bandwidth-expands the monic LPC coefficients: A[i] *= bwe^i. +func bweExpand(a []float32, order int, bwe float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L241-L247 + c := bwe + for i := 1; i < order+1; i++ { + a[i] *= c + c *= bwe + } +} + +// smplLPCAnalyzeWithF2 runs the full LPC analysis over a windowed buffer: returns +// the post-bandwidth-expansion monic LPC A[0..16] (A[0]=1) and the power spectrum +// F2[0..256] that the pitch and signal-mode paths consume. +func smplLPCAnalyzeWithF2(windowed *[SmplLPCBufLen]float32) ([SmplLPCOrder + 1]float32, [SmplFLen]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L255-L283 + var xbuf [SmplLPCNFFT]float32 + copy(xbuf[:SmplLPCBufLen], windowed[:]) + var f [SmplLPCNFFT]float32 + rfftForwardOrdered(xbuf[:], f[:]) + + var f2 [SmplFLen]float32 + f2[0] = f[0] * f[0] + f2[SmplLPCNFFT/2] = f[1] * f[1] + for i := 1; i < SmplLPCNFFT/2; i++ { + f2[i] = f[2*i]*f[2*i] + f[2*i+1]*f[2*i+1] + } + f2d := make([]float64, SmplFLen) + for i := 0; i < SmplFLen; i++ { + f2d[i] = float64(f2[i]) + } + + tables := buildDctTables() + var r [SmplLPCOrder + 1]float64 + bruteDct(&tables, f2d, SmplLPCOrder, r[:]) + + var rc [SmplLPCOrder]float32 + ac2rcDbl(r[:], SmplLPCOrder, smplLPCReg, rc[:]) + var a [SmplLPCOrder + 1]float32 + rc2a(rc[:], SmplLPCOrder, a[:]) + bweExpand(a[:], SmplLPCOrder, smplLPCBwe) + return a, f2 +} + +// lpcIsStable reports whether the monic LPC A[0..16] (A[0]=1) is a stable +// all-pole filter. +func lpcIsStable(a []float32) bool { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L295-L338 + order := SmplLPCOrder + if a[order]*a[order] > maxRCStable { + return false + } + var a0, a1 [SmplLPCOrder]float64 + for i := 0; i < order; i++ { + a0[i] = float64(a[i+1]) + } + m := order - 1 + for { + den := 1.0 - a0[m]*a0[m] + if den == 0.0 { + return false + } + inv := 1.0 / den + for k := 0; k < m; k++ { + a1[k] = (a0[k] - a0[m]*a0[m-k-1]) * inv + } + if a1[m-1]*a1[m-1] > float64(maxRCStable) { + return false + } + if m == 1 { + return true + } + m-- + den = 1.0 - a1[m]*a1[m] + if den == 0.0 { + return false + } + inv = 1.0 / den + for k := 0; k < m; k++ { + a0[k] = (a1[k] - a1[m]*a1[m-k-1]) * inv + } + if a0[m-1]*a0[m-1] > float64(maxRCStable) { + return false + } + if m == 1 { + return true + } + m-- + } +} + +// lpcStabilize bandwidth-expands the coefficients until the filter is stable. +func lpcStabilize(a []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L341-L353 + if lpcIsStable(a) { + return + } + iter := 0 + for { + iter++ + bweExpand(a, SmplLPCOrder, 1.0-float32(iter)*0.001) + if lpcIsStable(a) { + return + } + } +} + +// smplLPCInterpol returns the per-subframe interpolated LPC predictor coefficients +// (interpolation index 0) and the carried last-subframe NLSF. nlsf2a is the +// decoder's NLSF→A conversion, supplied by the caller. +func smplLPCInterpol( + lsf, prevLSF []float32, + nlsf2a func(nlsf []float32) []float32, +) (predcoefs [4][SmplLPCOrder + 1]float32, ilsf [SmplLPCOrder]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L358-L367 + return smplLPCInterpolIdx(lsf, prevLSF, 0, nlsf2a) +} + +// smplLPCInterpolIdx is smplLPCInterpol for an explicit interpolation-weight row. +func smplLPCInterpolIdx( + lsf, prevLSF []float32, + interpolIdx int, + nlsf2a func(nlsf []float32) []float32, +) (predcoefs [4][SmplLPCOrder + 1]float32, ilsf [SmplLPCOrder]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L370-L407 + interp := &smplLSFInterpol4Tbl[min(interpolIdx, 1)] + var prev [SmplLPCOrder]float32 + if len(prevLSF) == SmplLPCOrder && prevLSF[SmplLPCOrder-1] != 0.0 { + copy(prev[:], prevLSF) + } else { + copy(prev[:], lsf[:SmplLPCOrder]) + } + for j := 0; j < smplSubfrs; j++ { + w := interp[j] + if w == 1.0 { + copy(ilsf[:], lsf[:SmplLPCOrder]) + } else { + for k := 0; k < SmplLPCOrder; k++ { + ilsf[k] = (1.0-w)*prev[k] + w*lsf[k] + } + } + a := nlsf2a(ilsf[:]) + var pc [SmplLPCOrder + 1]float32 + for i := 0; i < SmplLPCOrder+1 && i < len(a); i++ { + pc[i] = a[i] + } + pc[0] = 1.0 + lpcStabilize(pc[:]) + predcoefs[j] = pc + } + return predcoefs, ilsf +} + +func silkRshiftRound(a, shift int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L419-L425 + if shift == 1 { + return (a >> 1) + (a & 1) + } + return ((a >> (shift - 1)) + 1) >> 1 +} + +func silkSmlaww(a32, b32, c32 int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L428-L434 + return int32(int64(a32) + ((int64(b32) * int64(c32)) >> 16)) +} + +func silkDiv32(a, b int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L437-L439 + return a / b +} + +// silkBwexpander32 chirp-expands the Q16 LPC coefficients in place. +func silkBwexpander32(ar []int32, d int, chirpQ16 int32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L448-L457 + chirp := chirpQ16 + chirpMinusOne := chirpQ16 - 65536 + for i := 0; i < d-1; i++ { + ar[i] = int32((int64(chirp) * int64(ar[i])) >> 16) + mul := chirp * chirpMinusOne + chirp += silkRshiftRound(mul, 16) + } + ar[d-1] = int32((int64(chirp) * int64(ar[d-1])) >> 16) +} + +func silkA2NLSFTransPoly(p []int32, dd int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L459-L466 + for k := 2; k <= dd; k++ { + for n := dd; n >= k+1; n-- { + p[n-2] -= p[n] + } + p[k-2] -= p[k] << 1 + } +} + +func silkA2NLSFEvalPoly(p []int32, x int32, dd int) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L468-L475 + xQ16 := x << 4 + y32 := p[dd] + for n := dd - 1; n >= 0; n-- { + y32 = silkSmlaww(p[n], y32, xQ16) + } + return y32 +} + +func silkA2NLSFInit(aQ16, p, q []int32, dd int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L477-L490 + p[dd] = 1 << 16 + q[dd] = 1 << 16 + for k := 0; k < dd; k++ { + p[k] = -aQ16[dd-k-1] - aQ16[dd+k] + q[k] = -aQ16[dd-k-1] + aQ16[dd+k] + } + for k := dd; k >= 1; k-- { + p[k-1] -= p[k] + q[k-1] += q[k] + } + silkA2NLSFTransPoly(p, dd) + silkA2NLSFTransPoly(q, dd) +} + +// silkA2NLSF converts monic whitening coefficients (Q16) to NLSF (Q15). It mutates +// aQ16 (bandwidth expansion on non-convergence). d is the even filter order. +func silkA2NLSF(nlsf, aQ16 []int32, d int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L494-L589 + dd := d >> 1 + p := make([]int32, dd+1) + q := make([]int32, dd+1) + silkA2NLSFInit(aQ16, p, q, dd) + + useQ := false + poly := func() []int32 { + if useQ { + return q + } + return p + } + xlo := silkLSFCosTabFIXQ12[0] + ylo := silkA2NLSFEvalPoly(poly(), xlo, dd) + + var rootIx int + if ylo < 0 { + nlsf[0] = 0 + useQ = true + ylo = silkA2NLSFEvalPoly(q, xlo, dd) + rootIx = 1 + } + k := 1 + var iter, thr int32 + for { + xhi := silkLSFCosTabFIXQ12[k] + yhi := silkA2NLSFEvalPoly(poly(), xhi, dd) + + if (ylo <= 0 && yhi >= thr) || (ylo >= 0 && yhi <= -thr) { + if yhi == 0 { + thr = 1 + } else { + thr = 0 + } + xloL, yloL, xhiL := xlo, ylo, xhi + ffrac := int32(-256) + for m := int32(0); m < binDivStepsA2NLSFFix; m++ { + xmid := silkRshiftRound(xloL+xhiL, 1) + ymid := silkA2NLSFEvalPoly(poly(), xmid, dd) + if (yloL <= 0 && ymid >= 0) || (yloL >= 0 && ymid <= 0) { + xhiL = xmid + yhi = ymid + } else { + xloL = xmid + yloL = ymid + ffrac += 128 >> m + } + } + absYloL := yloL + if absYloL < 0 { + absYloL = -absYloL + } + if absYloL < 65536 { + den := yloL - yhi + nom := (yloL << (8 - binDivStepsA2NLSFFix)) + (den >> 1) + if den != 0 { + ffrac += silkDiv32(nom, den) + } + } else { + ffrac += silkDiv32(yloL, (yloL-yhi)>>(8-binDivStepsA2NLSFFix)) + } + nlsf[rootIx] = min((int32(k)<<8)+ffrac, silkInt16Max) + + rootIx++ + if rootIx >= d { + break + } + useQ = rootIx&1 != 0 + xlo = silkLSFCosTabFIXQ12[k-1] + ylo = (1 - (int32(rootIx) & 2)) << 12 + } else { + k++ + xlo = xhi + ylo = yhi + thr = 0 + if k > lsfCosTabSzFix { + iter++ + if iter > maxIterationsA2NLSFFix { + nlsf[0] = silkDiv32(1<<15, int32(d)+1) + for kk := 1; kk < d; kk++ { + nlsf[kk] = nlsf[kk-1] + nlsf[0] + } + return + } + silkBwexpander32(aQ16, d, int32(65536-(1< cumulative CDF + LsfExtra []uint16 `json:"lsf_extra"` +} + +// LoadSmplTables returns the runtime LSF CDF table set, built from the embedded +// seed ROM (lsf_seed.bin) and shared read-only. +func LoadSmplTables() *SmplTables { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/c697c36ffa7875c304ceea9154be30b66cada914/wacore/src/voip/mlow/smpl_decode.rs#L23-L43 + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_decode.rs#L29-L31 (seed rewire: build from lsf_seed.bin) + return loadLsfBuilt().tables +} + +// SmplLsfState is the cross-internal-frame decoder state. The LSF block resets the +// pitch/LTP predictor fields to -1 whenever the stage-1 selector does not match the +// previous internal frame. PrevLagSamples, PrevLagblk and PrevLagidx are encoder-only +// (pitch-search/lag-predictor continuity) and unused by the decoder. +type SmplLsfState struct { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/c697c36ffa7875c304ceea9154be30b66cada914/wacore/src/voip/mlow/smpl_decode.rs#L169-L186 + PrevStage1 int32 + PrevMatch bool + HavePrev bool + PrevGainIdx int32 + PrevFiltIdx int32 + PrevLag int32 + PrevFracLag int32 + PrevLagSamples float32 + PrevLagblk int32 + PrevLagidx int32 +} + +// SmplAdvanceLsfState advances the LSF predictor mirror exactly as the +// encode/decode path does for an internal frame with the given stage-1 selector: +// on a no-match (intf 0, or stage1 differs from the previous frame) it resets the +// four pitch/LTP predictor fields to -1, then records PrevStage1/PrevMatch. The +// encoder analysis runs this so its PrevLag tracks what the entropy encoder will +// compute (driving the abs-vs-delta lag pick). +func SmplAdvanceLsfState(st *SmplLsfState, intf int, stage1 int32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/c697c36ffa7875c304ceea9154be30b66cada914/wacore/src/voip/mlow/smpl_decode.rs#L192-L205 + m := intf != 0 && stage1 == st.PrevStage1 + if !m { + st.PrevGainIdx = -1 + st.PrevFiltIdx = -1 + st.PrevLag = -1 + st.PrevFracLag = -1 + st.PrevLagblk = -1 + st.PrevLagidx = -1 + } + st.PrevStage1 = stage1 + st.PrevMatch = m + st.HavePrev = true +} + +// SmplLsfIndices is the decoded per-internal-frame LSF index set. StageNraw[k] is +// the raw symbol count for coefficient k (len(cdf)-2), carried for the dequantizer. +type SmplLsfIndices struct { + Stage1 int32 + Grid int32 + Stage2 [16]int32 + StageNraw [16]int32 + Extra int32 +} + +// DecodeSmplLsf decodes the LSF block of one internal frame (the first block of the +// frame body). config is the smpl config (0/1); intf is the internal-frame index +// (0,1,2) within the 60 ms packet. It mutates st, applying the no-match predictor +// reset in place exactly where the reference does. +// +// The four reads, in order: (1) the stage-1 selector — intf 0 uses dedicated row 0, +// later frames pick row 1/2 by the previous frame's stage-1; (2) the stage-1 grid, +// whose CDF is selected by (match, current stage1!=0); (3) 16 stage-2 residuals, +// each coeff k from its own CDF LsfStage2[stage1][config][grid][k]; (4) the 3-symbol +// "extra" LSF CDF, which always fires for our 1:1 path. +func DecodeSmplLsf( + dec *RangeDecoder, + t *SmplTables, + st *SmplLsfState, + config int, + intf int, +) SmplLsfIndices { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/c697c36ffa7875c304ceea9154be30b66cada914/wacore/src/voip/mlow/smpl_decode.rs#L218-L291 + var idx SmplLsfIndices + + // Read 1 — stage-1 selector. Frame 0 uses dedicated row 0; later frames pick + // row 2 if the previous stage-1 was nonzero, else row 1. + sel := 0 + if intf != 0 { + if st.PrevStage1 != 0 { + sel = 2 + } else { + sel = 1 + } + } + stage1 := dec.DecodeCDF(t.LsfSel[sel]) + idx.Stage1 = stage1 + + // match := (not the first frame) && stage1 == prev. On a no-match the four + // pitch/LTP predictor fields reset to -1, recorded BEFORE PrevStage1 is updated. + m := intf != 0 && stage1 == st.PrevStage1 + if !m { + st.PrevGainIdx = -1 + st.PrevFiltIdx = -1 + st.PrevLag = -1 + st.PrevFracLag = -1 + } + st.PrevStage1 = stage1 + + // Read 2 — stage-1 grid. Outer select on match, inner on the current stage1. + var gridCDF []uint16 + switch { + case m && stage1 != 0: + gridCDF = t.LsfGrid.Match1 + case m: + gridCDF = t.LsfGrid.Match1Alt + case stage1 != 0: + gridCDF = t.LsfGrid.Match0Alt + default: + gridCDF = t.LsfGrid.Match0 + } + grid := dec.DecodeCDF(gridCDF) + idx.Grid = grid + st.PrevMatch = m + st.HavePrev = true + + // Read 3 — 16 stage-2 residuals, each coeff k from LsfStage2[stage1][config][grid][k]. + st2 := t.LsfStage2[int(stage1)][config][int(grid)] + for k := 0; k < 16; k++ { + c := st2[k] + idx.Stage2[k] = dec.DecodeCDF(c) + idx.StageNraw[k] = int32(len(c)) - 2 + } + + // Read 4 — the 3-symbol "extra" LSF CDF, which always fires for the 1:1 path. + idx.Extra = dec.DecodeCDF(t.LsfExtra) + return idx +} diff --git a/pkg/call/voip/media/mlow/lsf_quant.go b/pkg/call/voip/media/mlow/lsf_quant.go new file mode 100644 index 00000000..d32d7747 --- /dev/null +++ b/pkg/call/voip/media/mlow/lsf_quant.go @@ -0,0 +1,502 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "math" +) + +// LSFCBCentroids is the number of stage-1 LSF codebook centroids. SmplLPCOrder +// (the 16-tap LPC order) is shared with lpc.go. +const LSFCBCentroids = 16 + +const lsfQstepCondMult = 0.9 +const smplPi = float32(math.Pi) + +// LsfQuantResult is one LSF quantization: Qi[0] (=grid), Qi[1..16] (=stage2), and +// the reconstructed quantized NLSF — the same envelope the decoder rebuilds. +type LsfQuantResult struct { + Qi [SmplLPCOrder + 1]int32 + QLsf [SmplLPCOrder]float32 +} + +// st1Tables is the per-codebook (voiced/unvoiced) stage-1 table set, mirroring the +// reference St1Json layout dumped from the C smpl_get_lsf_CBks(). +type st1Tables struct { + Cbhalf [][]float32 // [16][16] + CInv [][]float32 // [16][16] + BitsCond []float32 // [17] + Rotcond [][][]float32 // [2][16][16] + CbCinv [][]float32 // [16][16] + We [][][]float32 // [16][16][16] + Bits []float32 // [16] + Wie [][][]float32 // [16][16][16] +} + +// st2Tables is one stage-2 table set (per voiced/lowRate/qi1); the per-coeff Qlvls +// and NumBits rows are ragged, so they stay slices. +type st2Tables struct { + NumQlvls []int32 + Qlvls [][]float32 // [16][numQlvls[i]] + NumBits [][]float32 // [16][numQlvls[i]] +} + +// LsfCb holds the loaded LSF codebook tables (the C smpl_get_lsf_CBks() output plus +// the static smpl_lsf_tables.c constants). +type LsfCb struct { + St1 []st1Tables // [2] + St2 [][][]st2Tables // [2][2][17] + MinQi [][][][]int32 // [2][2][17][16] + MaxQi [][][][]int32 // [2][2][17][16] + Qstep [][]float32 // [2][2] + MeanV []float32 // [16] + MeanUV []float32 // [16] + RegCond []float32 // [2] + MinDistV []float32 // [17] + MinDistUV []float32 // [17] +} + +// LoadLsfCb returns the LSF quantizer codebook, built from the embedded seed ROM +// (lsf_seed.bin) and shared read-only. +func LoadLsfCb() *LsfCb { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_lsf_quant.rs#L79-L85 + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_quant.rs#L117-L119 (seed rewire: build from lsf_seed.bin) + return loadLsfBuilt().cb +} + +// ----- f32 scalar helpers (single-precision throughout: qi[] is decided by f32 comparisons) ----- + +func cosF32(x float32) float32 { return float32(math.Cos(float64(x))) } +func sinF32(x float32) float32 { return float32(math.Sin(float64(x))) } +func sqrtF32(x float32) float32 { return float32(math.Sqrt(float64(x))) } +func log2F32(x float32) float32 { return float32(math.Log2(float64(x))) } +func roundF32(x float32) float32 { return float32(math.Round(float64(x))) } + +func absF32(x float32) float32 { + if x < 0 { + return -x + } + return x +} + +func maxF32(a, b float32) float32 { + if a > b { + return a + } + return b +} + +func smplSign(a float32) int32 { + if a > 0 { + return 1 + } + if a == 0 { + return 0 + } + return -1 +} + +// ----- vector helpers (faithful ports) ----- + +func subVec(y, z, out []float32) { + for i := 0; i < SmplLPCOrder; i++ { + out[i] = y[i] - z[i] + } +} + +func dotProd(a, b []float32) float32 { + var s float32 + for i := 0; i < SmplLPCOrder; i++ { + s += a[i] * b[i] + } + return s +} + +func werr(x, y, w []float32) float32 { + var s float32 + for k := 0; k < SmplLPCOrder; k++ { + e := x[k] - y[k] + s += w[k] * e * e + } + return s +} + +// matrixMultTransp16: y[i] = sum_j c[j][i]*x[j]. +func matrixMultTransp16(c [][]float32, x, y []float32, lenX int) { + var yt [SmplLPCOrder]float32 + xtmp := x[0] + for i := 0; i < SmplLPCOrder; i++ { + yt[i] = c[0][i] * xtmp + } + for j := 1; j < lenX; j++ { + xtmp := x[j] + for i := 0; i < SmplLPCOrder; i++ { + yt[i] += c[j][i] * xtmp + } + } + copy(y[:SmplLPCOrder], yt[:]) +} + +// getMaxiK: top-K indices of the K largest values in x (descending), ties toward the lower index. +func getMaxiK(x []float32, idx []int32, k int) { + n := len(x) + used := make([]bool, n) + for slot := 0; slot < k; slot++ { + bestI := int32(-1) + bestV := float32(math.Inf(-1)) + for i := 0; i < n; i++ { + if used[i] { + continue + } + if x[i] > bestV { + bestV = x[i] + bestI = int32(i) + } + } + if bestI < 0 { + idx[slot] = 0 + } else { + used[bestI] = true + idx[slot] = bestI + } + } +} + +// lsfWeightsSpectral: RD weight = inverse spectral envelope magnitude 1/sqrt(|A(e^jw)|^2 * scale), +// scale = 1/min. a is the monic LPC A[0..16] (A[0]=1). +func lsfWeightsSpectral(a, lsf []float32) [SmplLPCOrder]float32 { + var lsfw [SmplLPCOrder]float32 + for i := 0; i < SmplLPCOrder; i++ { + eRe := cosF32(lsf[i]) + eIm := sinF32(lsf[i]) + accRe := float32(1.0) + accIm := float32(0.0) + epRe := eRe + epIm := eIm + for j := 1; j < SmplLPCOrder; j++ { + accRe += epRe * a[j] + accIm -= epIm * a[j] + nr := epRe*eRe - epIm*eIm + ni := epRe*eIm + epIm*eRe + epRe = nr + epIm = ni + } + accRe += epRe * a[SmplLPCOrder] + accIm -= epIm * a[SmplLPCOrder] + lsfw[i] = accRe*accRe + accIm*accIm + } + minLsfw := lsfw[0] + for _, v := range lsfw[1:] { + if v < minLsfw { + minLsfw = v + } + } + scale := 1.0 / minLsfw + for i := range lsfw { + lsfw[i] = 1.0 / sqrtF32(lsfw[i]*scale) + } + return lsfw +} + +// LsfWeightsLaroia is the Laroia LSF weighting (inverse adjacent-spacing sum), used by the +// conditional path's rotation weighting. +func LsfWeightsLaroia(lsf []float32) [SmplLPCOrder]float32 { + minDist := float32(1e-3) + var invDelta [SmplLPCOrder + 1]float32 + invDelta[0] = 1.0 / maxF32(lsf[0], minDist) + for i := 1; i < SmplLPCOrder; i++ { + invDelta[i] = 1.0 / maxF32(lsf[i]-lsf[i-1], minDist) + } + invDelta[SmplLPCOrder] = 1.0 / maxF32(smplPi-lsf[SmplLPCOrder-1], minDist) + var lsfw [SmplLPCOrder]float32 + for i := 0; i < SmplLPCOrder; i++ { + lsfw[i] = invDelta[i] + invDelta[i+1] + } + return lsfw +} + +// lsfMinDist pushes LSFs apart so consecutive spacings exceed min_dist (SMPL_lsf_min_dist). +func lsfMinDist(lsfs, minDist []float32) { + n := SmplLPCOrder + var dlsfs [SmplLPCOrder + 1]float32 + dlsfs[0] = lsfs[0] - minDist[0] + for i := 1; i < n; i++ { + dlsfs[i] = (lsfs[i] - lsfs[i-1]) - minDist[i] + } + dlsfs[n] = (smplPi - lsfs[n-1]) - minDist[n] + findMin := func(d []float32) (float32, int) { + m := d[0] + mi := 0 + for i := 1; i < n+1; i++ { + if d[i] < m { + m = d[i] + mi = i + } + } + return m, mi + } + dm, minIx := findMin(dlsfs[:]) + if dm > 0.0 { + return + } + for k := 0; k < 1000; k++ { + delta := float32(k)*1.0e-6 - dm + dlsfs[minIx] += delta + if minIx == 0 { + dlsfs[1] -= delta + } else if minIx == n { + dlsfs[n-1] -= delta + } else { + delta *= 0.5 + dlsfs[minIx-1] -= delta + dlsfs[minIx+1] -= delta + } + ndm, nmi := findMin(dlsfs[:]) + dm = ndm + minIx = nmi + if dm >= 0.0 { + lsfs[0] = dlsfs[0] + minDist[0] + for i := 1; i < n; i++ { + lsfs[i] = lsfs[i-1] + (dlsfs[i] + minDist[i]) + } + return + } + } + // C asserts here; we fall through with the best-effort spacing (do not panic). +} + +// condParams is the VQ_temp cond centroid (built from the previous frame's quantized NLSF). +type condParams struct { + st1Cbhalf [SmplLPCOrder]float32 + st1CbCinv [SmplLPCOrder]float32 + st1We [][]float32 // [16][16] + st1Wie [][]float32 // [16][16] +} + +// vqTemp: Mahalanobis shortlist of `surv` stage-1 centroids (plus the cond centroid when present). +func vqTemp(lsf []float32, cbhalf, cbCinv [][]float32, cond *condParams, surv int, idxs []int32) { + var err [LSFCBCentroids + 1]float32 + var tmp [SmplLPCOrder]float32 + for s := 0; s < LSFCBCentroids; s++ { + subVec(cbhalf[s], lsf, tmp[:]) + err[s] = -dotProd(tmp[:], cbCinv[s]) + } + cbCentroids := LSFCBCentroids + if cond != nil { + subVec(cond.st1Cbhalf[:], lsf, tmp[:]) + err[LSFCBCentroids] = -dotProd(tmp[:], cond.st1CbCinv[:]) + cbCentroids++ + } + getMaxiK(err[:cbCentroids], idxs, surv) +} + +// lsfQuantCore is the faithful port of smpl_lsf_quant_core. +func lsfQuantCore(cb *LsfCb, a, nlsf []float32, voiced, lowRate int, cond *condParams, rdWAdj float32, surv int) LsfQuantResult { + st1 := &cb.St1[voiced] + st2v := cb.St2[voiced][lowRate] + minQi := cb.MinQi[voiced][lowRate] + maxQi := cb.MaxQi[voiced][lowRate] + minDist := cb.MinDistUV + if voiced == 1 { + minDist = cb.MinDistV + } + + var lsf [SmplLPCOrder]float32 + copy(lsf[:], nlsf[:SmplLPCOrder]) + wlsf := lsfWeightsSpectral(a, lsf[:]) + + qstep := cb.Qstep[voiced][lowRate] + qstepCond := qstep * lsfQstepCondMult + + var qim1 [LSFCBCentroids + 1]int32 + vqTemp(lsf[:], st1.Cbhalf, st1.CbCinv, cond, surv, qim1[:]) + + rdBest := float32(math.MaxFloat32) + var outQi [SmplLPCOrder + 1]int32 + var outQlsf [SmplLPCOrder]float32 + + for s1 := 0; s1 < surv; s1++ { + qi1 := int(qim1[s1]) + isCond := qi1 == LSFCBCentroids + + // lsfq1 = 2 * cbhalf[qi1] (or cond centroid). + var lsfq1 [SmplLPCOrder]float32 + if isCond { + for i := 0; i < SmplLPCOrder; i++ { + lsfq1[i] = cond.st1Cbhalf[i] * 2.0 + } + } else { + for i := 0; i < SmplLPCOrder; i++ { + lsfq1[i] = st1.Cbhalf[qi1][i] * 2.0 + } + } + + // qerr = wie^T * (lsf - lsfq1). + var qerrIn [SmplLPCOrder]float32 + subVec(lsf[:], lsfq1[:], qerrIn[:]) + var wiePtr [][]float32 + if isCond { + wiePtr = cond.st1Wie + } else { + wiePtr = st1.Wie[qi1] + } + var qerr [SmplLPCOrder]float32 + matrixMultTransp16(wiePtr, qerrIn[:], qerr[:], SmplLPCOrder) + + invQstep := 1.0 / qstep + if isCond { + invQstep = 1.0 / qstepCond + } + for i := range qerr { + qerr[i] *= invQstep + } + + var bits float32 + if cond == nil { + bits = st1.Bits[qi1] + } else { + bits = st1.BitsCond[qi1] + } + + var alt [SmplLPCOrder]int32 + var absQerr [SmplLPCOrder]float32 + var qres [SmplLPCOrder]float32 + var qi2 [SmplLPCOrder]int32 + st2 := &st2v[qi1] + for i := 0; i < SmplLPCOrder; i++ { + qi2i := int32(roundF32(qerr[i])) + mn := minQi[qi1][i] + mx := maxQi[qi1][i] + if qi2i > mx { + qi2i = mx + } + if qi2i < mn { + qi2i = mn + } + qerr[i] -= float32(qi2i) + alt[i] = smplSign(qerr[i]) + if (qi2i == mx && alt[i] > 0) || (qi2i == mn && alt[i] < 0) { + absQerr[i] = -1.0 + } else { + absQerr[i] = absF32(qerr[i]) + } + qi2i -= mn + qi2u := int(qi2i) + bits += st2.NumBits[i][qi2u] + qres[i] = st2.Qlvls[i][qi2u] + qi2[i] = qi2i + } + + var iAlt [SmplLPCOrder]int32 + getMaxiK(absQerr[:], iAlt[:], surv) + + var wePtr [][]float32 + if isCond { + wePtr = cond.st1We + } else { + wePtr = st1.We[qi1] + } + var lsfq [SmplLPCOrder]float32 + matrixMultTransp16(wePtr, qres[:], lsfq[:], SmplLPCOrder) + for i := 0; i < SmplLPCOrder; i++ { + lsfq[i] += lsfq1[i] + } + + surv2 := surv - s1 + indChgd := int32(-1) + bitsOrig := bits + // Beam base is FIXED to the initial lsfq (C memcpy BEFORE the loop); each refinement flips + // ONE coeff relative to this base, undoing the previous flip. + lsfqBase := lsfq + curBits := bits + for s2 := 0; s2 < surv2; s2++ { + lsfMinDist(lsfq[:], minDist) + w := werr(lsf[:], lsfq[:], wlsf[:]) + rd := 0.5*float32(SmplLPCOrder)*log2F32(w)*rdWAdj + curBits + if rd < rdBest { + rdBest = rd + outQi[0] = int32(qi1) + copy(outQi[1:SmplLPCOrder+1], qi2[:]) + copy(outQlsf[:], lsfq[:]) + } + if s2 == surv2-1 || absQerr[iAlt[s2]] < 0.25 { + break + } + if s2 > 0 { + ic := int(indChgd) + qi2[ic] -= alt[ic] + } + indChgd = iAlt[s2] + ic := int(indChgd) + qi2Old := qi2[ic] + qi2[ic] += alt[ic] + qi2New := qi2[ic] + qlvlsDiff := st2.Qlvls[ic][qi2New] - st2.Qlvls[ic][qi2Old] + for i := 0; i < SmplLPCOrder; i++ { + lsfq[i] = lsfqBase[i] + qlvlsDiff*wePtr[ic][i] + } + curBits = bitsOrig + st2.NumBits[ic][qi2New] - st2.NumBits[ic][qi2Old] + } + } + + return LsfQuantResult{Qi: outQi, QLsf: outQlsf} +} + +// LsfQuant is the non-conditional LSF quantization (smpl_lsf_quant). a is the monic LPC A[0..16]. +func LsfQuant(a, nlsf []float32, voiced, lowRate int, rdWAdj float32, surv int) LsfQuantResult { + cb := LoadLsfCb() + return lsfQuantCore(cb, a, nlsf, voiced, lowRate, nil, rdWAdj, surv) +} + +// LsfQuantCond is the conditional LSF quantization given the previous frame's quantized NLSF +// (smpl_lsf_quant_cond). a is the monic LPC A[0..16]. +func LsfQuantCond(a, nlsf, lsfqPrev []float32, voiced, lowRate int, rdWAdj float32, surv int) LsfQuantResult { + cb := LoadLsfCb() + st1 := &cb.St1[voiced] + cbMean := cb.MeanUV + if voiced == 1 { + cbMean = cb.MeanV + } + reg := cb.RegCond[voiced] + var lsfqPrevReg [SmplLPCOrder]float32 + var st1Cbhalf [SmplLPCOrder]float32 + for i := 0; i < SmplLPCOrder; i++ { + lsfqPrevReg[i] = lsfqPrev[i] + reg*(cbMean[i]-lsfqPrev[i]) + st1Cbhalf[i] = 0.5 * lsfqPrevReg[i] + } + var st1CbCinv [SmplLPCOrder]float32 + matrixMultTransp16(st1.CInv, lsfqPrevReg[:], st1CbCinv[:], SmplLPCOrder) + we, wie := rotApplyWght(st1.Rotcond[lowRate], lsfqPrevReg[:]) + cond := &condParams{ + st1Cbhalf: st1Cbhalf, + st1CbCinv: st1CbCinv, + st1We: we, + st1Wie: wie, + } + return lsfQuantCore(cb, a, nlsf, voiced, lowRate, cond, rdWAdj, surv) +} + +// rotApplyWght builds wrot1 (=we) and wrot2 (=wie) for the cond centroid from the rotation matrix +// and the Laroia-weighted previous LSF (smpl_rot_apply_wght). +func rotApplyWght(rot [][]float32, lsf []float32) (we, wie [][]float32) { + lsfw := LsfWeightsLaroia(lsf) + for i := range lsfw { + lsfw[i] = sqrtF32(lsfw[i]) + } + var lsfwInv [SmplLPCOrder]float32 + for i := 0; i < SmplLPCOrder; i++ { + lsfwInv[i] = 1.0 / lsfw[i] + } + wrot1 := make([][]float32, SmplLPCOrder) + wrot2 := make([][]float32, SmplLPCOrder) + for i := 0; i < SmplLPCOrder; i++ { + wrot1[i] = make([]float32, SmplLPCOrder) + wrot2[i] = make([]float32, SmplLPCOrder) + } + for i := 0; i < SmplLPCOrder; i++ { + for j := 0; j < SmplLPCOrder; j++ { + wrot1[i][j] = rot[i][j] * lsfwInv[j] + wrot2[j][i] = rot[i][j] * lsfw[j] + } + } + return wrot1, wrot2 +} diff --git a/pkg/call/voip/media/mlow/lsf_seed.bin b/pkg/call/voip/media/mlow/lsf_seed.bin new file mode 100644 index 0000000000000000000000000000000000000000..a9b8004cb917e060bf56f6e2cf82b537e32ec8a7 GIT binary patch literal 30359 zcmV(xK${%*=H8 zikW4xC7DrXhKR5o%$bo?nUzpkT|M2^-BaB&@BOHEZpx4*sj@qYoY zeEZ4)-~l6G01hG{G=y#70=J`}b6Jy`xbVs4kK3#d5MSGo-6x#4t4l*GC=g3)1)E_1 z7j3IUxdeMX*<~NCDL-hpW!2+mdnA=`a5r^|xsg8KeDn20UE12PF(f@=Jfs+7-k??I zj)s_fJga-tUAaF-ajge!ktuT4ansBS9FTbjH_NLasgPg4pz$w<`Ox}AVQLIgFBP2( zZs6gv{3>nc`Z{;8w7RhnW|6aVBhJ11+YfAAWM!r}7Ib1Z7wXAuEhXHF{$6hA@WF06 z8cp7~w=^5Tbu3a_zXb9{+#E8E#w4}1Qzd%P?)SMosLPKdna2Cpc-40i#OJ{Lc;@1t z4FjQQ+BfWQhu!vo-Ddcv0dN`r7(t8tDMhj6;p~t$D85r3`}LR1v^?rpLir*X)R_?2 zKMbR${XV8;G2QX?W*P;|+x#30=lN_(axuXSh>4u@D%hOktd_S%Sna zLPh51Z!)3VA08;v=7d}3j75j@Uea}IM`|xsV_uqfsl-|fQC?eXK)rHhH7#xLuakc} zMe3Ls%Xpn;$mR2TXZ&4?`3GgFzfYZ8-pF_Sj213uBZywcQ{ld72#&7oPIzVggNk_h z==#RU@XXq?JDZacn^wqTij*ezh{M7f7$h~f_0okJGxqr0j52Bt04aS|yxZfP?%Xqk zEm_d%vAckKZ%m|@v>uNYj|F=lbRHOJ$0EBQihA^=sSj`Xx#i)8rI5=nUI<+J4>2zj zwE!X8urU*2{>xQUbdHN>Gs0kpZM)`%*kUM+w~x3mUO|k_QBxUMDqPs8fRl%VH1Q30 zZ!v?j4Xfo=h3jZxvfa3yRB9Ez z_0662)y0|7d<1j5>|UGGZMFJQ$MH|Uu)CZNm&@*QxZLuu-*h~If=-*??(&<%s!z@e z=fAWDY<|q?!DEp#-!7>;s6L2z>2O&S^}k<}bx}kTRdPe;Pd)ttEWa=Zgs}Sa(azWqj&o;cb58StmBHMv{;_Z zM`!eP_bB{NXY3l2(FkJRZ5`$QCg%#r7s-ufQ}M{Ut#1-;_=h@P%%^5SO}ST{S;CUnJEF2 zx&23vHkam>W{Rn(*X8_+MJS3y@sQ1rxZr?cddlL(1Lgqa^V$-k-E;ryvpF4hkImu6 zM*99(DL?Q#y;ul#`*kDd{+MV_k0CDHM?hZx|2{7-#OyF+^SND4zZch!wA-HFtZqVl zR)-t<@@4GMrpED{A9L9~)~Ho%3Ou`|?y2Yp+_){*{QJ4SYpLNxz=2sDVavh0$oQ>o zM4^=^R9dvi`byv4rB+Xk&dp7RE#_M<`L@P~U9eWA)Edx4_FeSTMH{MB@&qEe7L)v9 zayN(>ltKwdBvYA?k#D~_7!O!2dZk9K(OK;NDRp0UO)s6rmnyU-2zNT1F00e&wIhZ8 zFW)DS>RYm=5jdw zhVcA_Z@BEuCblOd;bI76zPIS~~1* zO)M?>MKqD4I5(H9zKmry$1(np5Xmf*CnL0`)=taFOgZOPv8il19M3M^zP~pewn&&% zCSPfBdvGnE+SSxb8eoZ31~g*!TRphjYeUl9<5ORBYbVulGz9yQsrJVmuA}D0ulhD^2U{&ONQo&tPYVZADj+8wDuWZ3(vzg+fwQEVa@Nl)2QW{A)Vu89pNF56IVu7Sc%-I60Z?9tO z6H2~4I5sAb{XaE3M!;MQnss=%6pEvLf!S*7=su4oT$q>)a2WK|9c909XE|)?Wox4= zo7?xV4wa@y{W6BamKd3y+J5e5Q273lx%nin;nC=LJ6)YDm&bf&>Hw*iCdeN?KHg5@ zGCotRP{@@!E1(i}w>Nio(0B?hggWhBr`?VuVqlVfSn*Ad&t(n6!vr&ox!30BoRZ*-L*cyZK@qa*QSCbw=2H#Q4C)x6;VFjH1e@u zc2H`j0zhtVY9S%(t1HnzdpBn;6sIA(Q~_X_(Q_*Y8J&eH(qplam4%hghx;^+RIN9| z(c;qD!svM{}k9+OJ#IC)`x{{@PVVgJ6{Cnx3W2K;=yEpc^A=Z~w*oqgDIf_|G*tpGG z*LEx?qH-lRGp^~Xzl99lmkEqBBR;VRpIDkxHvASB$%?6jK_Ct;ZD!&hMAx%|A#-YR z&Zi(V=Qew};e|1HfFk$I?CwmRe+SB}uWsMfbia{N>m4wc1uVl=efqteGj81Pn z{rqq?ioAeJoeA=|q7FTm+|t}mrt%~j(24tqhMv#`bG;8fd@6Rh9Jt4A4kaj$>X#$U zx8+Wk(+zr(CTOJ_zVANq&_R#|TgoK;Lu=Q5i5BzNJr-{PsQ#0HZdwcmWGRo^k~i`m zfeh;QZ9^Zp>xuB}zxn03XT}966A>5W@s&?~lI97q++@Ze^lk#BSNF5yed6GB+A7k! zrlRbH2`NusnoF8FZ2R61`sX|nSSSx`SacyevAKMY=9pbegX9j17%k3C$L>@&cjIGo z<%}EFNF-XUR#_Zd%BLMF4vo~)BLM#J)xt!;2Y8C6GH*Pp!#g*jKq z+Y}-+HZdw6-ps0d`Htm-c`v`6#C_Ybus`XOGX$35`K5_r=etb|Ni;iK3?M22hpT~0 z69>olH>ZQJoXg^iloq?ylbl%FzkPgTb7?%62s&L39~L#neXP&U9#dzu4qM0xjqum5 ze%|Mrr_X8}4!g_l*UU09BaF=4yPw&@pe@dM)Sk>Uz)$;h^Bz3p!j|e%yM5x_Vh3^q zP0EqI3(4Cw{^+WOH9V395#Vt@skG~-ux54wN^|)y_azA|8o-Udcmg+g;tcbCf<;c; znbmZQCbz9a!_VlfSZ>%OXGz1i?k%5zAHb(VUN#a1xy+hyQit4 zt&b_ySWvs$Npt|*9qafIcz#T6muP+r=E&b-)Oiae2tS<;iOna$3V;bbTmbTX4 z|7zZb8xmF&L6F4!+@7~p;7#2Kdzpj!`0CBIzvlR36Qv}mHZy8{64fD+Tp~9V7xZ{g6q+XZCcqxAzVS6ejGemke4Z%ZZsJpw6fPG+Zcj zakV=R&Yba!Yz!Yisufht+&d-UTQQ+SY?oUts-|(a3B%l#lg!NJWi2RUB==WDSU0$olY2&j(jog9FLz=@YdumJUnl zT>0cwG3ussy6FtPcY1l;y}UIh9+Io2hy(Ko&tD^>M%T^j0k@Xk-_+VmWhr%lu%*7M zlh#;OT|?Cv45z<62OBXnWHM@u8b}ZN6|7Hx4#>SUj~>#(8sJmpW!bQb8G-4r!J>w= zJ}r&ycjlX4zav2;Ryl0Z4*3O|{S$7gogQz>H@UQOL;;j)#=E8A?zyTZjYO)~x;Vl( zeqz538$N}jMwu{3r6*!lo~;VqM$P$-!Yq3@%b<08tPH82PU?2p<#j#zlA$UTyGjyL zB|6KWB}VB$SfSM!^xlA*!*>>QTt=OGHE_Phfw+>xzNjx2Y_GPj?*-W8&JRyXKwK_p zuciWiqTVeQz&$ZJGi#t;tmu|IB4MICc9Yv$u2ePj@^t_8xGN+?h(EkjW|3JmmW!Ml z_WZfwF2Bf5#U!|>z4chud0nh$gjtZuJ`~ZYt(t5rC*>D#%NK^5RvjjeUI5&GmFfk; zji}fUwke*o8~KiFGXj^+01G5)Cl#=2EueN_t41~YQ?nY|tqLMw`i&Vo12L;nwJgFj zxmo*)zj2N#0h`2NR@(uGv$kC`5jefREH|mt4o%K=`7+yI&R4Y8zjBt$ka+w6Q`}A0 z!xmROYLqCaX9b4AHqQR0Lo`HpPKWER$hiRKKAN)WY-r7|PIV1PrBEmwb|5w{j_9;a z-Obl7U#uP!Y5wzVxkKsZTa~cXpa%hIY4**kH9yx%_vkDdvkn>6=N>H#gZp}Tdi?u~AvO)B(oZv2`u&oIOCBo&f-nMSa=gqAvufGPLNj7A)X)oDih zZE#wW;$9p@3^)ksjq|-VMQh~^Pe(Vci=noU{EVjAmJ01AUG*!~y$U6BOs(xwQA9eo zHHzAqTAf@6+2X%w58lxjG%nw(AIPxuSgq0q01~mp?(Cp&IdbGrVX0Dx+CdCr(i<+H zsiX`H^wQ-_$>9yBT_)rXN&T_$d9_eBx8(QPqrs5AxvHW1`A_p~Oaf|Q0iXv}7HySo z4VZu5B@DCN^mrd`Rx6zroMX`%EP9J%_oW+)saJz|i`5kE^$n)Dswk+HIx^KC+(Ipb z5k?5=vm{(+9C?0ZvZ=p-bXgX3X0{GvFEtd}o98+UV6z;yOW8?YAb)DBO;u#pNjUze zSM*RwQ;n3w9Cr?~DF(7)7f{3BfZu?}QXIh$!=VX9-fPFk$LKZPY%&G+Veh=})@0MX zo*~29-L;@aDPv0PzKn`P=|gbTlZy=WQK(wy-u{9UP#c%-ZMm&#L2u^(ouyKVWX|De zDLc2az7VlOx^F*lvJ%~}3SogplhI%tWpIrbHmjF4(RLg%noJx(W7{}+e#@L1Fd20! z05F&(h+?Wmy#3Q07n8u{&A+r^B24Ol5Cy0MjCvU=Ga8KycwYAMjb3jzoQO0)Zo23x zqUz?9=u=j?7J?8q>+SL!g&XWy7B}#}X7U+LMUz6VDJnG*k<7#9?JnpBsN@nryP-E) z2z{l!5f`Z^X5ZKCcya+-KEe_#R{ljMk2>Nz!Pv8Vn2?a0#YG*h{B2C|xT)_-v8sr$>9j z=I*>NCS!{;#34pd>e}17BPBPHbPe)2EuWlo!)&u%t=0u#%hkx`(E7U`x40iyV~Y{? z&*SM@Mas^Co$2VlMWON}E(r?5b=utU#mf~oHw3_ZoljT88(r{+Aq!ya{M(UmK{((@ zdlWi}JLm@Mx&?)K@ud@FwOSrb4136}gXYwo)jV#6o%5UJiR|L2PpvZOl};-fOT^KE zflH@PbV@Xa|KpC%Xto#(X1&g=HXBVge9)r7)CLWt@?U#!aw(qsxsyH2!i7$DVn8<5 zWRG05Nf0d{H{o4}&D~p4e#FPp2c$hyjT_t=_j)klcdE$ZBucr z#zfN0j=EZ|X3NIjI@;PN8^{1OY*ec0YOlv6b4A^Gu(!TRm)!AC+nVS`lcl=}@XU^9 zrFEAYNHU87+&#>?bmD>5rj7xQ%B(kMiiNS#;_BpZ*o7JY;=rebw1C!ZHt6*xqn`c2 zZ<~WWtHLaF$t-%aT&uv$h~A*r*?8g3o9`U~R5HX46u`oiNxBcsczhz|fcRzcJA~g|rSkSR$orB>d~U{W0yJPset<0i{On z$aT~9h8iQ`-hr`lerE63)L;qfEe3tcE#?FhHilSV=D%E+RJTC^vw~+{9e2_%`m**8 zW_T?`kmJH=iQCjdOD;j3b#$XyA&bSM>Xzzu`{vd-g5#KVd2?;rs~&76(K#{@#mvH? z-j4cfrz_eS0>gj*nyW|ba$@&dlac6u)8YTLV$cJa5K#kqv(795-6r!#JDVR_Rge;v z!wOJn@HQoxfgv|}>cmo`$*KiSwQKLr%k?0&(COnVAP*CE4yflYF1N^LuI_DUf8r6# z^;%5fmO?JE&2G~$JpNw6N}FV8)AtQRwNY zE8({OsGpx3*K-G5F8z>LE|RM_e1*Ooc)!}QIoaNL>b-i43ovL5I(C1zR3#L0kkYiz z-g1%^ot+7K-5y)4n9n86tm?B3T@0;Sqk`h&V=Eh5BXOtI_*aklh|w;w>5M{5>d~u` zY7VG^AdMMhFaGid&~4T!Z7M(wTDUXs=TzeKBS3P}w=7*|yecyt0sQ z_2@CVlZx2nn()=^sGh8xQzzZ{ zJnelJnqCqjeXUQKKXl(sD?XS?!q{ zv6)oH!qN_zJ+%;^_c0_kJF3RLfK`EoletM7wf4-VX1&OK^d#X{@;f>Pg<^|bKnLu} zsqyi-@nR;Qh~PhZ1RJa_j!UZ309uRAIH=P;dA)JMVo~YLCau8;`pxadckh%swFIqG z7&I;P4Z6EOa$--amui(b%VHHMA+eWi^N2!qn-xcW=MJ=Lm_IW}@k?+^Z<)~=RVh&d z@m5KNi(X&7%7PgUR!1}GfUBK3Wn5Gd@S{RN#$eKQW@_oewVH;vTUn!1A{i|#mx}<9 z(o%1ZHj;u7=t3mRYZNdT6sNDcL8Hl~E)2L96Gjt6?)3%73%-+G@$tzpWCaO5OqDAX zpvH;|)ncM^PmBjWd|Km2XFJ$RlS-gl*&2Ugt7WVpVAKhnYFvO=bPB7;-*Wrym3YTk zeU|0ujPQorvbxssy1BuaT4qVn)Q+}|PS(OfOY80>J8Z`2ks%u-1v~?jrbeGv*0xa- z_BR@`DLTfFu{C#o?Qt>93Bh24=7Hs0a>!~V$2c~>tVfCnodyZi=GdXsjV8&bXhw&h zcj=oUswSoFHMyiJMtll>g`Um}$R!(}A~Tu7!`iNkVrbT1*X3FnWzfYDAJSUi5!gv{ zn;KXj{$$7<4`HxYN@42k9;Zwq>K!!UlamQ^^XW@%Qg1MlNmv#A9ak%_HTAQkVqQzd z`AeO>Z4|o9VALCQFS@?TsD`CD(0lWx4L#8bj0UYm30bc^ujyHrAzCwGJjem>`%hFg z%Cd%-0*E$Y($wC@t~C`m44?uuYXQf(Cz7@+Pem`4VN}aCZchacPtu$^ermQ40B8S1(;|Z13nD5MU*Nw*ua}A4 z;+q%F?n&Hgv&N*c1j0>d%g*Zyu*z&Q00s-?+GkScC|M5(Z9=;++9hR=D)}C@)(8ll zg0c5MNzO(nBPJ2+)|nu|ge(Er3L+{!h%~vUE-YQMZZrC6sFdSQs4E}zu#052%HmL8 z>6yA;VmBL;47JH(mJMh%BKF9#?990Td{fPdN*ki2_D^v*MpvU@*l7*@(Ww+m^=y?Z zFK=e(OyVoPC~U53m1bi#X9o$@*OX7#5o${nxqOgC&1xVtwU8dh!NJNC?OYw~gtaZT zt)$NS>MLzxrS4zf!+EgEWYn6pdIRy*K+qnd7g$BQRMqZVtAjcn@!F_^p!T(knZSpu z21si*D4inC=4ouHvvW-^hx95xRdD|#bIMg`i5g(FG{oXQs2Fh1F-fL^Mx%4+PJP{G zq(}V%I)p0$kTdn6sTgClYNleet5${h=Id5hW@v!#9ma=93canxHUf5&4cXZM^<2eA zf4i#W$aM}auGYx3It!d!ub`$TU|LtdHk8YGsMWtx6|z3z0EG+1Q&C)_sjKSqk4?l( z^!CQKUV#NDuI?5C8Ya2DgG^H(FSV4NG!7aD}M=iX=A3e%{0IWLnZnY&K9r)8eU+k3g++Y zn{CIT%FgkfeVUto|F8BOwjowx8mA5(w%_^X+-MsUSwOiAFh71+H*b66Ml4j<=bbA- zJ(Ra+!f#CEJR~wOJ?&;xH=c7oZ)ld@oWuHC25qIe-01l0JrlPJnvNQZC0}01G&Ln^^YJ+S*Kf`QKvv>gpaI7@3%#A5RCyKfSY6aO+>}zp0~-B~y{x8Y?O;U9D;E z>L&=9@h?7stY(`L0bnx-zyQ>8;ooU~sY_y0!5UO!Q+0mCcyc1;F$!@5pn+ixY#v6m z4%Da#Y8;BG_y66OMlprrtG`F)l#tEfG)kmw*Z4117tgz$dH^IQeC<#I?@r4- zY*&wuW>kHJw2FgZ0F{@WPRaUOvNxnBI-Ty=PDaw!&NR#(W8@Y#WWi_FA|?T~mukCx zyci%P&8>R}^Z5WO>2Ifsw7!rP5wiw+ny#F`(!o<({`>~4M|8LavS1)$fq~|#KMjP$ zZi!Qds;ml^)Mm>|T^87Eap|1$xp)6KeaH3DW11)sle;V)wO6fVV1Q-frB7`g9!Trd zL$O3mKKRMdnFm^h6M~$e%a&oF1*YxdXPs$k-bHr#!UoBN(%LYRX}f+Zv?S-rykRQC zH8b+cWVFg(3MTUq-7%hFx1kR^IJ@J0trxB?&!C-DkvGERViAi8Da2@9?C|7V|K;G_ z*%$lIN?Tf)rumx~xs?M0)|u6aSujYZ*>Bxi3OH?HV*d-o{>A_Q{=@kHxBte==dW}U z`+wnz*gYLpPu9{9zo%+*WpWtglWQ7wM#oYRmqM1bt?W#sVKJ+R zT)jFw8U*FEhR)^bkzqn?Cbcvytd8b9u$s%IS4so9(U=#~^SOPL-Pu%zP+p88fq(Vzp=6Y{%Isb#@^Ymt>uAm+1zI+cW{5|<+GaLD7d5#&TC)zkTxs$WZ_gVV$L z0AVfLP!P44b$YEuqcOt_s;Sed@CWT)AV9cyHV{Ez06{E(iD2%;*dpaoq`>Jf9$GJm zukV{{MXL**YbU>1oHln=wtu&5rww?X%}nXZ39^c5D=ykQ9j1?WsMt&3PQD!!a z4RyV?vr-KE6_hJCe4&(8E~hr%znOFfEn<^UFMPH*<;K;@L4%EPS--rRS#Tj*txcv6 z>VM(-;M>SfQ04ZyKm>B?AS2;NUFr4@hCD78X7DJKJdLVo59%huX#3!RU*y!43f5XM z@MHt2xwO3qw_hr+PJmq(p6<*bLmd|n7fT4eu3~(%1hLv1YgebU2($I-!M*u3#O-M) zEX<^F0lDSe>_k3n7EuPN_s6n+3!#tH6({mhP|0U-8*~1`aL8$qvwN%4sZcr*cEdU; zS2WamkWQxj9+wRRO$LojDJEIn5tqkDIDdBBYDElsnNT&fzC4L_*DlPAp_KNEN2_@Y zy|ek=%m~Kmt-X9aoq>7eKDLJtqA_l7_4n(O35S^41wNQ75zZpNxoh2>%mxuTpM|a- z6!Hn1LCokmJC#kw-DbU-MH{Lp#xkQmuLCmZ1w4gO^K81m5DEAYy93sV6m0Ejdvekr ziu#?H#b5%|T)sjpF0r&hZ^#pH+c3xsSPVLe+1S4!9G(oh10D=VQ7djXSpb#Hh-f?l zrFdR;`Njj}Sh_9I%L8&7yR~3L{BBBIgZ}dKXRk zQX=6;bOHgVk9BFMv>1sIqdlOL3x_yd`gn9Kne=&_m`+G1cOEaUg1J;IJKSn;d@c2IR{x9!6gwI_5>OOeB;m#3y;rz`5=t>=P zV|UA3eR_6r+1}c6ZE>MsAYJ~~Q)}CCt=h!vOl9+P3z(7!X~17HjYakrS5Q1^YO~hYt$YeH1g}zcC8ghqyej*&^aTC6{6@dV)TqDsi%OCH}A>CE= zt1A;oUsKE8!Kh`ZrEzmMhtiu`7H5kYl-*IeHk(HH{atlSGvjGo*x%SRK0gshByIC}(0~$* z`OQP*#D#AkLT4_2ejh%6>FyomLgn)#q~e`tcaX~`9`3`Jn|F8N+RFPIaC7bLRdYv2 z$Nmb?bN0sGl7-SrUYQzG(B9%1y`RsS_j)tK3If$ss=w_nnOtSm8;9+$Y6WL;bH+EYO36r&w)c7q@%?V zT-Zgrcx%59g#{foSGVWWHVK2#36}F=Ou?fI1Pi0%>9_|oDy6;s#o?US>A($&fuYO$ zlOv%p5!*w|8jV7rlp(5;Cq<}-fPt7gbaJ&?!euk#9qI%WirB+AAqHEWHr$3_7z|r1 zCe&h5YK4NqFRsJa-oLvC*1z|~jTNlveB$OZ)LQq+Dnzb-ZFheLC12|O$D0#)-@Bz7 z6Xv1T_YM{kh>+fM_Nzt4*i;ykGn(E=r899aNGRz%5ssd5JAv|mY;r{ZQFxtpDz zEQM`~zMi^?+3^sj;tooJze$G}ONooJ^P8(SXZN*zOJoX2lTzvJl1}5mk^XKi);JzH)C9Y(BeFM%&Nq zm66_h)0682M%FCt&%uUm*VfXfavg}n$ z89l7y`N($5LS%B5Oa9iuuz44-T4C3Ne%7TgYc4ej8C`5P%pA8g|iMjJZ9; zvWQY8<|Due=+xr22ir*F`HDxk*5Kxf3%3rJi9hR={k?g#h2vI zEf$jymp(}Cn#JcRra2>8Qcr$MFHX^3#2Sgug~{?PO4@h#}e+y8uo zG`#fXHrROP!4}+b_LFU*nY?|!Y-)ew_5wI~@#^#aDP*ATjqheQN*KNFo-R(LOoHBqq4m*R%w`m`Nu9^jF34svBPv#V^B?cn z-umGf{^)-lSt>sKhkIys=Yvh8{@ky2mhqZb?`=9-s=qj#1unny^~NmHdhY1yqKnye zc4;fG7q^pcm#0IpfXX<3BUMN{aDqNxS)NSi{g6^WWT>7lp!rC^4nan)uo1dH9}oK> zT#IP{v&}Rx@CW$se}lfX_Y8UYH{i+c&!BVd9|Nba-M#~#`ss7~$sc`uXgh!HcoVr= z`T4Hx(uq%Zteuy>J6iHkx`d?F`OyH@cH*;HeAKHF4YthBl>D%f$H;tm?+*6i-#>l` zRlfH07^!^si|gR!(@(dc>Q}$n!5hy!-@qErZye76t*379%{zK4j#o!9O6{rLrHK+U zMDDCk&y3~(c6S$fd8QCRnb)MwWGNqmwQOd$MY~XPgyMd$O{WnIk+dfr$%Ho&54cdB zMkeU$um6un$Y1;!`Mdu>{_=mpfBQ%HZ~q7K_unIb_XG6$AAmPr{t9{dEAXvfe)ZJ! z=I8gFrz*F1wWm(}uyj`~?f5@KYpX)J(i}MR1^W8a=kR;~?Y9r$vlY(|t(6zQJc2Kr zzJDFO_U>;F@wzwncdQK;?k)q}=dREANUv;eW{o|~4cF%iR#9hDYj~mPHSlTmwY&4v z1%gu$hiNjCj0W9SgP7Wg2JB9P$AF+ktK#;{_|K7dUi#)feB$r^<&mM{r_cBBinnfT zVOL&zx{EcOJl;U-J~&##S}*+gLD|w#u|H)YUtL@;nEN~H50)2(L1tH7_44X?#HOK> zY0;JGv=f&1)l_a~6A>o{=#+xN?vZfZMc}bUF4O1@Vue^J|3~YuzPA0{KU&|t_Z<7l z@nh33e)tr9@Aao2Lm$@s_Azqe{5MDXOYhy^)t`Oyhoc2c+v$~q1rNRZgnw}|Yh!oQ z829po?k%Nu*3J^6sN1B~=|!B{SHH=JoCN(wFc^U}VzI35kH}B{8UDrh@GrkZU;Z9_ z!0NuMVv>r@z|( z>RiN`u;e2VrZ$r#7zF<&hL+8g` zMrX+7CKv#W5Tx_kKOryu1Nf`Y@i+eN8|clezk@#d+b^D=?|txxr|26*)tr0dvmLDY z?B>iS-tpGefI=C|KZa^=)zn7bk}y}XJ7Av^`{># z1MQcePFh;d{jiob(a81Xk{4jrH9QH-r-O*3{fx&tJRG)LOd6x0EfkCS?1Zj{AzG!7 zNk08M?7x2p{_1=1*H1n{-uV16^48hG|h z`~bfGIr{5Q(9?|{LnnXr;2HAa2cO?Z&z=3|2)}gl>21Sh>c#CX%ay8s*p`qh4_8sj zA#C=99I`xg7vPk#6ce&g*gKSkd9 z*_Y4Iw}1KdGx&pdo)GVR-L(i zV+*ONJb!Cz6>qO=S}#u{^d54{z}!M9 z8L%4VEEcuAo{L9AM6_Ki8Q@QsVgbL`fti#t4(HXeiENY*S#Z!|(8$%2f!g`;MAYp< ziCI+-7%{27HyOo=(b|R)900VK^%{*-%+{I)WGSOZVYj1p6d+iD#SEBrYGMN7$W$`s zkm&ZbxAE=kBcr3;E8U#Z$&e~!zf^lkJIhrpV#;SHlzdiK>#;L49P!#wgHp<4 zbT5piBObq-*o9sp8P1c(qWhFeh-A!Y!;4Cpm#1x@x(x1h#TN1JmnxufReDJ_lK@zq8i zn^>{!%?6LYdgB~dk-BD2o4omew5DHAl- zU?Lo5rGTSen$Cs7ZWo52L|#Kn_-vTDi#uv|C1P$TZbM-h08M7C$!t`}#XZ%N$19fR z*Z;?Q0iji2*;vb4S)_)=d>k6M_{DAs77q>e&CaE*avI67G?$CulD=z;)7c2Drgzt* z=0=EatL>|r9?kd&A(BRt#pd&|uoEI~f$+Mcg+jt-gY{~KfX$;nF6R?oj93s65fmx) zcy%fi3b@@?#H>|`IowWXCg>t=tPmwcERX0>#J~!Td84Su45K)1$6=z5KqH~==(SRl zL{0Y{?ZMaH`(jx`>Fa+`p7xVlua(Oq87KM7E9qQ1J?tjE`(Qc&@CTaS%`QzOt#W41 zse^LCV^J^$`|?})j0e=R$yK@W;xLhg;19I5j8CK@9@HRYbcrM5V;SOmBSB?o{YMic zDGxD1tK|{_jXa-Tj`$r`6f&xWV*Wr!B4P8poH&L;fQ3-;)M_4=91W$N!=x`k1ZYbG~xFU)oua}d3z>ek{2%J1twKf6Aci4vT+ zvv;$U3t$RnPw%DSvEs1DEFB!ULL`mD4oJzOQqHVQ%!OPk;Uh!{kdPC_d`9b?aaSfB@DsK0;?b*BM0Cx1G&7tCdmIGd z5T#)v%35r(TN3@P#cNm;@lw(UD>z+E zQzO}!4}wOp>~8yij#^BhjRg%Tcu*<>{93B1u6$#AbQDAU!6UX&7>kO*r*f; z89ib&pNNNus~)gPqmT%>%rUP$9`zAPXbdJ&aC)6iBH@Bf?frYE?#BA#Re)6WtF`G8PN!TuSj^z!zQ&#h2N_htZo725JQ9JWv@1P( zhuw*bIC!i2gwaoEkB;fVg ziL+@i>11MqLUScI5)CGiU33y>m8}%kl%3Z+ovYmXLCdRZkGwzY9_G zsoi6xY}g4ZiS|q`7gECkCrYe|!zCQI`DlnZnSe&ZWl*xQRKOQ@J8+mN4Zv4K~#N`kISBU%rQR*5Fz0E^V51*GP7M$o$#OeW%*rnbghmB^NK)SY4#D?0d4(Ad8 zr|aq-F>;IhyXw=Cw?)c5YPun z#J$C+8_@{p15M>pChSJkA`Z24DOE@k_YjD}XHseL;V7Z0`5mxPLrfP6-bW+E#j41Q zH(;|~#%_^efpjY7^}6h+h4>Kxi={~gUhrYN(`mC2bq)e1gORvNE#ySJUSojh4<4)Y z#hi~Kgg0!_8&yQ>L+%~o6(Ps{GaL<;892PyLt`2;Fuk**zP(-BZD7W4Q5M>(2| zhI}?qCFODHHM^x3A5SnYBA!5`Rb=Wu8P(v0wQ-jRar%Qc!lpq9ydmUynOLTxJ+@x# z)<0e^V;wcG9xoyE&U3e>Kn|0Bc4s4v345C^+*v9mtb&fJ-OaHuDrfi9JSdO&AUV6E zBQiUZkKk$nYfv0mA5SOT5K$+jrul3#p9p#|Er(Q@otVhK80H8awRNgMfVlm|Fvl6{ z@0d)534&=hs8n*EvZJkl5RRRP5W8@kIC})(X%tkoD5x!spx$8I?y);DtJO-B2w)&0 zH99#FJ2Rk4g6e3ltt_rXr$0PghTAId&LgC2SGQKC(Vn^sQzPXPI?#00Gc%dD(pxGY zF6W`4zHVxHI*am2q?-Kr$O{4X!-?@?6p;;TjX{$TB7DVYd+ZN#HhN#yLI?j=Ve6(!_-!f7OEuTifMO9;#xj{AwSMqkvlks!T9 zmnHOB1EALGB|PC!ec$JwgBNN^ySp18sjGvszDBrZl)B38jY(*rqpf{*VYZ0%x7Ahc ztd&O*dPj5f`rPaYN^d6`(Dp(eX7#jHFD*=EP!74dwY)HuB3f`~%ku1K0_OL(H|-NT zMjRH>dpoOF<|jv@L|5zYAx(@HG9g64V-EFFleuhiIO2x1G7;gja@jt2C>#ui!T~32 zP|74Ssf0f`Kfj0lmw&kpRlWUq1Frn>zg;)ioc!lwu;a}Ab-eDEzuBL*H^2GK!2~>T z@=n>ss;L=WFItD{YPMF2!**G7)xl&ThD&HY^V4ZBrV|g5!#f2ps1i%GoJ&j7iKvI* zSQxd>#COSDGmwI}|cZLfQO{OC6F(Vre^+ppCf&mrgk>hUsC`|6{4 zsQcW%Z5FMx3m@Ly$bi(&#{0zoOb~Qj{>^e26W6`*d}%a_i2EoyS)o6 z-AGA;f*?pJB7&kIHrS0Vw$h-YlprYriqfcrfY2VhA8I%H<&Dk0Ue`~>xTxGZSrBidc&R#cv}yAZl928sb#?vA`(yk~ zFCEL?GJl-cnZiR^u`9+~9nQ(kOb!hiZdrMF|LzcCK|M&Ol(>#-=LWd%%*jeih}^t# z_VmH&>HBxb@7%UMJe1TnW{-ats`aKl%t7nH8c&4;*Ry>P%_k@-arIj}S#9@vZORmWt%9tLII28PJfGLKwL- zloaxokMwG--kV6I8N6)SQm>sU#pwwZ4`p5i8~d23$Ux zN)CiY^QUDT%8ZX%F=x_*xI-yvyJLvMFgGAD?LbQO&K)V_GqPLPtz5YzU`hC{C*f+( zf`>WWYfc)Zeyuvzm~;9_S)%^^x~-lA+D~lv8+5m9lfTQrbM-|bf%eTO^S4fSb|3^q z&Gr~te>^98`ScOz%96vD&+#8y)pUTAi55=t8d0?`l|%u6Hh;--n^vt_G|i{DDK3LJ z^&~B_Ie7U(*KgrwGv5Y}G=JJ0X|H~-Da=m(>G|kULoT!&iSQoMToyga*{JGt{w5#W z>nHbyO!iP~t=%8CaDwX*@{DujVlStTTH*jMo$hN=e# zgrEJ}x{cGvIo~=GF~#wA+13DOwX1c9!Y4bn9zPH@V}$F4nyirdlYKnS=aSFeE}mRp zk{-Qs)^z`4x%;ydqt=pgK|yJ5S_*NKSIwU@^gv!_{7w?@ZCp>vJuBu-`yOi8v3`j1 z<4Z9P8rSL~-7U@^ix^|tQd_odtU+hRCO_-9CwEM>JYQcLF@1z-YkAC)@z!se4u&uC z8+Pi@-tf7jof<33_k}I<_s%$#pPdl4Y-XT)T4~PS-LYY7gO|@+Iwd5Je4E1_QtXY| zOk(Ect0TULSeZQtvAT06dW6+e@+Af4pD#!I*{!ZRLBjqo!Gx))%D4tdEKx$ls=(o8B_)}%GkMW;hhybwX^AARTC-x| zoEa$x^7fFV-(ebZ2*2hGuL10FVSadWzLYOAN!$ENU!qx2tE zhL3h6mSMDZ)tRcu$qo-1b0R#A+Utv=X88`UKb{e~e9p)PSC6EI2hR?4eO{8Cl^C@t zXyVXI#W`8qL)NaCzjStaSqh2OHUzKSxMW52hK07hVMeAGPbKTz-!$CqVfbj1M`xlu z20T2m)yv}E(Xa^?ZAYReI=pQt+CF3WpxW|%5rNLl$FsHsc=$Au$H}6Crj40aQ=6X< zwJOl3jTDXJ$d`>Q3R)PLot>7P6dkr<<+6nnXCBT=*-0`F#DCtfX62Hgm8+gziniBy zP#-c}|7l~Sm+iHdiqK(ltrel(hE0Vro?4emwoMv*Yl(A?MQ+ z-{S4*Sv!fn-?i;`iFNpsXw^qyhIhkFZ-(1=T@4>H`o_fwr_p!MhPt>_wVVqbr19u% z=m_JBXSTWNoUI8T=Tl!9J;|=VIBe!5f6I*0lKrV$XG|Xb@@!H<^d(Zq`R&zci+d4{3U@-s*u80vcDKD!z0FPkHrXK7H_Ai(ZGA4C zI~+4@qR*A;{E)!mt}PWM+0oOjPMye#Ts3{rsWT;Mq049b*;F6SiQl|**2K|?hxVoH z-X6MP#r(NmlS-qLGjmeoV~BS^YI#f7hipyUGO6u)h|1k?qmFQM|{$6I4B{|U}G#jh)cg*wjI9!ssdBNy`r^@nE zLe|a;nBr8LotwEkHgxmaW%H)b7`#6%mRRvkTefT_(fzVD63kPGjsED)53yg&zupU`+YRmY%`>*uqDF|!{O2L zERk0Y)Q^+0)TcGV34djZ*77?ECnsrg8I-zz9h{$UB4Tk&Ha~_7%M3U|vz6`8y46LI z%TsmRaR)lKjZ|lIUr>6~2P5!-TM+>+s`wed+>tGYvjPvAH5;1N-r-cNSlL+XX#$ zFO?-M@f&WbBNfm>p;&Q%g@fyq#bNu7U%B<{)#qOL`KF_GUt(y`R8K2)1+h>t9VV)% zY=B@DgITacP2M&i;sy!^mO-AYqWq95*kz$D5Q~Mbr=e=Bj=EnNH2P{vMIw`_Rd8ye zvi9Izt?>1Lt*+(LMz|j_SV4W@hVqZl7PrD%SKW0@9lW~`xnz`;yu7-tZ^WI?(0aan ztFN=RqN1+4)u^CdP3Pg;ulMgO5|*tSXDY9*K73ij%kOZbv37%})BM@X)=ikUbN=Fy zBZnLLIC%!Sn5oNcfwgjK+aXek)=!66Mb>~6$g}2h&9flalA}+9oc3>x!@U!o!nUF|(Wv)C3Ar9fJ|mHter|^d8>cYdf5| zV6>&Cn5SVheDaF8sv9rgJ%=yP@7!%Zl(J*Px(U7xI=YI?>EKV%0WgIjSp>7#^kP^j zCt3x;inKHaLe$vo0r9X+ML8Pw>auv&X|UIXV#>Q5g1v(TB9~$)vKLB5lta}7T`o&t zIQJC1*=VaG;_y}M7Z=`yTgx<6?R;luU5B@o(E;`vd^rt=SqT@u!;d%D@`EQhc~8~i zTBXB5Qx@0q0A$$+MUDk<)LAHTtA>K%3M`hIPYqOiNttq9N1$<{8CE=j#%G2{x-ZV znP)D~5y%^l-`xq{JBxxx>Io@Ytm!x>{l<5AeKLBYrMf_(ZRWKkvHoQb^mTO}jhsA$ zD6y66guvw?yRu7b>)Y>q>;ZUtt7gw4cLQ}HODh55b(pkSCZrlsO!LE#VZjj%&4;2P zd|Gu>5mdSfWh*>BUX9IB8&d}7e3dA+`jmQTo}tU-4qST;S{CZ@+=6m1e}GTtqQ>d* zg{lU2J}XjBc6PzHCl`xi=Z-X#YMTxZ*qU?Z+1EZ|8n2$K$VrM?v&7%UMqgb?#Aka$ z07pIu)^hkk5UIk}jD!dw*RlX&6xjn(A;*NvvD^>oMm#3ZvJlFK@T6X6p>U*_qc*D* z4mgVBhekYva|=xcT)DxEPr|pt$rf^Kj!=Ej)Qmguu6UlEj+84@9qhBK?HhD8B~Bis zDwL>exd+8HJcaIuE%`}1f@h7gF;wI$+fG~@lbKt3%tAUPqyMj**Zy3}}G++Z@$6Z21A6yE>t(CfG)#G;~dI+e>&^IMbNT(;B|vU}q2X zbe3!!W2&OS6Ugbgtt-6x8GihFf3qxhmYa#HlA@5u=ScMIMokP1Sg<-Vzy9h|nf85o z_o1_;Dr>oqnX+&VY!dRrAWVU&9tzvkm`Vw-Q$ryM_6($0hFOqfM6=EIL#ZaqvJ&!b zIVv+Qz{REU~2X=I5&R)ho|GVvi2LCU2Gtfvsife4SZ~m9WKX{ zQ?hoOu=dbP=sLN^Mxx-k`x^XuP_t`=pR=A+D&UI+iYihqZBzSkn*4+geMgk^ND;YA)`Fx9gkZ!`G zP1B&pff5Wm1_#}wl zHsd#zK8AOPgRBHJ&6P;i22b77_71+>K3PPZt^g-ZO{>u>_tZY`flm)EpS;-e_`##L z>g=c)u4X#?Rj{5X3W4=GE*lG;*@B^bVyJa*&c*q3l=3FRtzO}JW9^F5YBpu z*veBb!NE~dN~9lH3m-S?aXAKa>*39|VNxz^F(0unb{CNMQ^;A*f zW|C_+QW0)~4Se}9h*VVH1v}L=5+O;SClhbFA=4lmvdk#jJP&dQahbH;K{(_jrktB0 z+fGQcm{Q*cC~+0at%sgNGmZEhoxn5DzR`lm*7nPJ3=dWbX*q+j+grZC(~9*YwfS7B z@zg|ORC?aE)#k>`a@6OkXgT?=X2My}jGoSQ0r_q7+RWDh5-1Cs8&gONXpS zmqi;BV?BVy8JG^4#%#7pHe{M{Ip+Hy)r!xx%7t7rT4b z;Q5Jge;YL}U#vZ7bWrBG`>%*QTe)kcuZf(%2&}md;KH{A=h@&ZnGQ>&f+a9kMZ{YR zYm|h+uvv|#vk^A%Omd(`S9dn_&5~30Yleb3dKSB(bB()HPGY?98C=R3Z79|8jywmS zKjy6SmP(CQcf!@8i4!L+S{Sz2*THd;qoxzs;t?>M=?89%aS%XLQz4Ma3V^v>!E%^K zbAw?eTd)$g$j;JmMfG%8t*DbA+hg*h>LAI0r|NYI%G{-F#i1Kp;a$O0Lq5lRep2qUFnwnd|?BnB|R6!a-u7&r***+apF9tEBhdjfbdJYXVbPlK6E;YwH_kO`VE z2!?PCrusH0FcY&Vm8mt5sVK1YJ^)u@hAAo!CsyKV+Bly<%!$N3AxFm)nmY%U(7YwE zSSYs+)(H5^AW|yc2oZ{m0ec}C}IT~!!RLHjEuoQ;nLeVe@i&h(T6b`z}Q$lm% z@i#iKDW1WSi_pB*T7kpl%Ii2U&bbQDik3TR2q;RZZRQ$O@DScqZ}K!0v#>ePQqWKX zWmVA918p799SC~HU}6SlOdGItfFTT)C)lzHb+p`O7|UdHQ=no7LuC07auzxmtGmH) zhBx?NYCMdms7Ww`#hL|^+4Lfq$EIe$3QDvD){7M4Axu#z0?H(`qF55kEsx;RvVj~aiO62S*ScV9fua4FMbSkkuD^rsYl-e!Tu<=Atcft5 z!JGsBEY2*L!v9)l9GGy`BIX%hP=M#=9)L~^KN7A_NAlERYbH37~6oA zDGX6hD=R)j}s83O54Q2Q*W+dOat7Q&k-m&%&LVe zQ-$26#7*1mZ6G1RlvLZsKjhf$Zus@~T=;lXu?^TegA-#E49A|trzh<9Wi!VRU!FaK z=o!l&mQnO7SSpqoR29`i*s8>`uZA>Rf%fVSsCHn}63clfpnVF5!_)FC?t+f0qyR&u zp_@*9gw8YV4<9td`I;&WhCz-n#0^{-o`e`*@FX$9Y|cC)RpBh+ee$AUftaDZ5%N`4 zqM*Ql8juBb))b{0co|BD2q+bcDM#UKpb4L&>%IRYJl}1?;mhk<44bj{(r0*aeqXSc zg%aNuY@NYn6u6HBFWMKzGa0@_iCL3jI!zJraG5J1kk2B==mHM!FsxT6pj7%E#~vl6ECB4D>6Ew<(=04nua}aWLmNdebVt-A7N7rlDb$FfXr9#mX{ZY&FI^f5JU~9fa*TFyH z=wtYHCVGNCmsT_wxhntaNBG!AD*slhJVm<&xg9@9w4WC|arh7u4T*Gs=r`b|#&>YSvk3nyyO1iH$d*J=j9X*7n|c172s1R^l1W zD0%_66TJ+Cw8$`E{{whiwai8$=MsMIBXnO3u@w=oyzID5xK$LR4niWXy5?juQo z4;L~QxoVIkf}Q_{%=*V)p!-2(q`$e6P^4+@K7C_W!^3XqesYF9m+qji$mdGb4Tp@L zwI#i><$l)>`0?(+`O@867Wp_0)KXEDS6Bnl$}DPd5#*Y)S@tB@8LP`v@UDTT@nWW& z>FhGNJ(5Ff1~x$5RCS7Lx}*(G`zzBHTd%|2O#>9%wztE}Xj4jl@RFKua3k4&pok<` z+!keB>w~t9wsIn=k>i}W=5O$&VYQJ!V{l+{OE)|@wv8AlnpV)U9lLVxrFTT-4lZ*W zprEd2;j=8hrt=H*JUN@a+S`nz$CL-Uc#_I%LG9HyU!nWW{Yz!Z>!;Z3NvI$QlCX@@ zAjz1g-vHa~=8#$Y9>Rm<(Q0f;I(R|(d$_#TQpn*e8@Mmee+Vy2mX9=7VskV`$6x7zM~$hA zT}@OtA~`Mn!J`&O=eIl~s?t%L7923#f@J2@ooDQ-y!P%Fq2)|b?CL4*M9T#7%7dnE z%xfl)KYF@9K0aTVv}X1g2OT+j@Nn zs-SJ*01n4+-f?)aL{(_B?h0I4XUY?3j@WP-AYM~Acyk*(N%7X?a;3J*O5ecC{MnX@ zB5mi@)gR%_iI@O8O);hJ7SZ$tx-Mq~d0J>Hkl5PHbM3yC5Ad-ecG_SKv4WPp*Q}_# zrWf7t^-atEkXh~~N?i2;gY2D0PMEPOq44b8PlU*a=kg+g{GF`z)f6SGV69NG0K$v~ zl))w_wV;{m9(&=40gINJ`R;|Y0~Ck>tDUS)aWtpbLgzXo9?vT2Jv6Pgm7~=?Ql7)3 zw2@+guHU}5a4phRkrJ8)DvNJCE$;<9KD5+9nZ*_Bx~?dBMgqhYHhekP zxU10jpelaJ@PR5kiH_5}r2417ptEG_cylEIN2FrpI3ue3_E&h{S`aeL$ykA4b(j@X z(*EHme0*46w0l*6%U~T%1&Khcs&DNvX+dyIR?X!%z3{91(dEPOL1Qd+71*<3p%O(6 z&VyomKBeeU2q$crildG|)o>w4Z`OG@H_wo#u;eM6TIHb5 ziccl3 ze_>sD(R9 z&OiMEKi_wrE6WUD8sIz1(aK0&B4CUJKcPkmgz2+5w)>#eLBN!bset1?N=$*#oJKf1 zQ=83_m;^P#!?o(hq0iv#GE=@vP&<6sW6z^EJ3JKJ28*dl8pqnCx#wbjQY-GzI( z3vA>mj)vXryr=Lucd`MG7HJP%QvMd6SFLl=o(P|4r!D3x3>f6I zW>4LdZg_vCIz4!X+h9E<0nHOCsq2{z9qzv@wy^2m$Di=#T3O7T5th1|stWWv2-Buj z;$gckQ*2N2L@r{9XC<@-s!_E2320elNC~ahv_k7DL#BAhx@PE#GGR;IVmje^h`pGj zxj*n10Jrj8Jb2l~ZSkql9LN}#DcE!G`6J@lX~ zjBm}DX(8r{)lJ>!?I>$~16@}RZX9o}KuOiCM$F$<)c*Vv{CIn%EPjRW5PfB#M8ni= z`qrGryTqOP^88wL>X!MFy@%Tk)RhzPH^EvpHp{jWGHkh`ktJ|qtW+@W9F&h%;i%3$ z52vPTu_a!0(6Y>0jvlbOjf6AWJfZdatI)PcQy{fo(hOI(+HlxfzI*S%?G;)SUvp&i zHF%gh$yiQey|Us1JUbcYYb?iRE4YT9cn5ErV*Sn3d3+V~v7r^0-@~UzH9P#xgk05u z!{#KMdkycOUM%0f$jjbPg=DG5MU}OYQ|$Yf`z|<}LEzecdv+`cFqwE&Zita~s*^%=+%~iN!4IA%85xWcPFWrCB z-3L7{uGJSMZ<*)qXs)d!PaJ|=sC44X`8C1eQF4+oCrEZ%gC>f;~0eG6QluPPcsR{S*DTEOHeyGGuG>)|dcY>ui=)>F70J3^gD>-neMgZn8H3`HCz z>sh%E;YGnbTVLpQd8V6*B4;iMP56sozEHB7ln@j{V3R7Pu>+z9a5=_FkZQ`|4<@NB3ofPTupjmq zFh#@5VV|?Sz@rix$4a@{Q%*tM3`3#7c-ASny3UHP=ooSfI^xHfs_-bDlG*sM6CdDy z>J%GNhx6NZ=;8->f9G`4vI*{1dg@{kPbijGlPWod#K$nCa^4>Z%B6wGA+fQrSTQP0a)nVv?j%Dx?}P znIgM=kY`1)6kYa1g)?91UIE2!$|UrxAWoPHOK28!9-5}eOH7wvgiEUiDRNjMU606% z&^}XDXfQT`*qq{N27ICR(B+3e!p*Fy0|Wv!n~AaKKEczvgxQXIa$LTm#-OpwQX3xi zz}t?x{oCeyT52iq%qH!qee@aN^Q(^fvYb6}VH-%b*nOy#p_Yn7$m7r?9Gfd)vbA?Y zyp@=2Q2QJx8q5<6DTaI}d5Tu{D26g8F_Wd>R!RzXiPz4Vr^Cg3p)RQ3FNMTrP z2i)D`r9@eVKY`ov<88D#BC{#G&UL|?hA3}s4qK!%#6PUy;qfoddw<1U#3 zE+jsh!dMAQ6&b4AU>%F(g2Tm{8IY{SvONIDtr=YRdN^py=aF3f`3XYFoEvZ}fX5!N z>@u8Rph@aG+aAL8Fgu>Ykmcns;8osOWs0jaV)fxy@Vat?qYBMcG9ACQRC6Fpfu!P!)I>g)puk1Q zH=0-qPo@jl%61#BK&uZeve3iiWFDH$a7Qe2Y6JlVz>q+a13w!1kaAeOtBa9 zq>igklM2E6Yq_g^jD(C~B!v_JQyC;>vYbt7ZBrS6ut`}I46#zhO^`0uONSIKj&&kb z@bywj4TqNVYk<>Ua-;y&43}08lBmqP0XIWjGz1E+vA0N2W-b;Rcx|kH3-6Av^H682 zSq>XLGrICwAKc1b=5DMhX4rt84|sA|BS|t&vJe6oY~3K3!;6MZ5_#F91nNg2UZ0|@ z%iyRP*YP;)HK#?4H%t3pWE;SMx?YIXF8mKrbU)QrGWErX@#kfqc&{a1$dR@D$iAR z2r7IE?~W}WB2uzloPYZVJU*7Z)X&*mO{fSaQ)O9(0FvAju7YKHS^^$BO7X@(kG**Tgx0n11RTXj%S z>3ia$t{9;&&k?B^I!%f>@e;bvZ1o^cs4;j@?%>6p0dslW6|harS_M0mwNoHb$zV5> zs*ti#mLc2j0G#*W^ZYtUSwX-xoly?eW-P;{XW-6eLz=Dcx0@J+5Mx@wWJb;nxSQmu z#Md4X((oESHpcjw$SK6SJYduWn8uk1!92lM2onoqAw*6o z1Clf~QXpN6GE0G4TekezV^Cls@;wK+HXKTGDtX*xmIg&0khuYmH|p~Z{C3}lmxty! z=n3WZy~EEDqRKaq(&DHNTbgpZgSeUZPb4oMF;GQF^6>L1&L&d)=B*)HpQ#p0Y#yn~ zMJf=kCW-V@;gAk(kqbFi6g{*OiU!e?QyG*G7b=dehB_aqz;szFoLyupVJnZ^^9JsP zs4#gdBcj^iS)#iVN388QIr7+Rl2J{VXdvVY#;iGe~lmw+iM7c*|jfP;o0nXsAaJ?KRCJWf&zUTQG2GE0m8A zvPY)eglFX|JWZ5@G*2wmwjLi-{Q{mIi}N$)YJo9>F&svD!5nHDESJ*3grm|;5G_zk zf<#TdgOIArWEv(xkvW&=b{r0oOpwwTqNgT;v8SP9wJJ*^=qj9FZp7i*r++=q!d8;k?bBnkQ6+G z3rKdFO)R`fxQdt`jqMQ5rAU=0*?{H`$$?lA-;vbnhVwP2v_QElPiPi=3odT7lV@=h ztrnELg8N59J@mx%pj|hA!G}x5sgcX4xmjr|2xyAw3g*noFk8q8f@MO^a#$uH<%V_g zqK&XtLf$sZ%ZHGIgyh_#l^L3GuuYYr9t+9343>Th?9pdYrhADsV)IBXuE3Gx=%$~6 z+NlHhY@x=`)t8}Z=^$n9zE62e?>oj zb^qP}>u-F&`efw%556D4kMIAFzF)t-|NKpvO!(h>db>aM{rpWyzkt7ywrrZ8ziA^1 z@%fJ`kg0z3^nUsF zgMfE`gnwE2<9lyUH|hUNA&Gpy!OuUOB25DS`OortWxb^N;a^tvBkAe=0Kb0ANrtbt z_v^>sO<8X*K|u8X-wo1_@_YBMKM4B%XcYWE`TuVJ`oqlM@`Qi&>BlcZ;(xsDN8J1E zw;2D`Ntt~A+kyX2AAZTEBrN;W-w#6c{l1k|Cd6_cA-oi|5HFhQQ!ZjlQsX9 z|8KH>{OtZ8yo_$y`u?V{_s8GLA-iE+Ph%#7$zqWgi%pU{0x`yz!Jrr{27|$(Ib5aw zHj})VOcs-(IkFZRLIzQ6Hisv|{Q%@e?o$jlhtUrzgJ!c?Tpk}`gnwTI2s1hTko`FN z5%B-C`UlVyNAeq#*^iFR<_j_Y-*5(#%fGW@dt(VrU> z8~uL(8vTc`pGp6%m+^-}{MRMp0s13-!aa)S3WR@{$)re|O>;%RyBYmTBvXjL#6!Ae z^5_=`VYp1eh&&kmH)Q>?@jT{l&J)(LXqqGB^^a!Be%Lg*#DDZnrhH@oPw=0%km<++ ziK0JkM!&+5hjxC8?l(q?#})T$0^|4cS+q=l@PG2AC_b0*2S3$6BUj;fw@f|BifNwW zZ)*QcN%RH%kC`$JmGO`9FHSNg=P%~1X^4@IPfI^RsB` z-`;+8APW8W{IUi7yS-#wC2h`M!tB?d|N3QeqyAf8|7fxWssE?13^w)O>iTD-c<8U~ zOY}>C{w9#D*x@a&`2jw81fTqYpdQDT;$3C<>26${=by**HTdTx zoOcq(*WsKaIOiZvF2IdNxTPFdrQ+*5@P|&c{R}Rv!cPkEzEYf?jbCKosQ$v1HQU3}|4&VPnKzsK$0@ckD!_6ok8kN4-}?kfB^A9v>Bt}J{n59j6K z&j;}RlQ_Q>_uay&Rk$J?S7+nr2XXcZ+d-aKQzf(17=!$3zGR&f@$FIJF&Dw&GXU@!eav{x<$}8}GW0V;|$wFL71^qplCf z?_{*cF@7X7dI4Yfg>zryL(lN5CY*W>cckGfarjvaI)4K{yn~zCapZY?;{v{Z0l#d) zcUy7y4gBp6-tz$0Ji#$faOiV<_bGnx3jex=FE!)jaz<<(<5WGPr;$;0f^p>tBfWr; zoyqvQlTj4TfN!|(Ev|cpbDrScJs5y-v5!&rmeKiyakG(eHJ5QFj1l`C_kF=dU-8}# zIN}wKxrcLa;Zry8#rHV5l5y%NqauybdysKBpRr><XD~@jb38$4v+D z`*QsFD!!kJdvbBzD-`n{g>|E=XHZcNN}hJx_G2uL-Hp!yYRJdA)%eOD98-&)&pO?T zQZL}LaD4GQx^^0!zKy=LqoTX$*elc#gC9lXW0|ET4Y($X(X7(C0^}Fauwz#p$^+ zDO@MEA_vDMPZ6#v!p%ov4(fvbcS0g%5g+JuuV{te;4)4ps7xv@l`S|8dd^Q2!d4*bT zp|mLcHW8ohMX`A}vlyQ}g>Tp3&^&x}7v2|zt6!i`4^Y-NOcd%>I(}A&6Ps}3Rh)Di zmwv_9e&L#L_|6^tz8*g<#!qta*$BM-D{47`zC_73*7qHZBR6ry7qlY=KmCHvT}O$9 zXipnzFUHpn;?~``l&s+j`c#LzU*o(F7*ZGs$&AnWjHZ2z=2FJZd`4U%_yMh(5poBarEJH_*oLVz8Brg zL?t`XJFo1=_}xDAeiwS1h<;_E>@AU=_qqt%QDruYPC}QnP-ZUr8Vn<{!x_aXXjcmQ zxEJk7MxPQ_Im&(WzvVl#UK%pi^7AjiPfHQ5on_66(rA zW$~zDFKSIf2QyL89@G8LgZZBImplTdsHs!c-C`%r5#dO;DnlGi_Z zIgIXHRK5K4WxVGg%07+SccQ!5_-Q%Xb{suAjc%@r&%&RJP;&!%uph-&qQY$`D;FO+ zf?gJ)eKqLI5mb5zMa7}^{rKZy)K-t0_oI|ksHqaYC_t~aq3nY=qa2+*f-asyUk{@2 zW2m73Wf!8d1oZeIE-FShYf)+uI$n*E%206)x>bzM?MJ7=(CI@sqYjl7qOe+Yw-ohO zqx@oYy&7FUf-;KH_DJ+G6ZalQ-)c}%3A%F}?I=T?HRx?A+E;}>m!jvDC@&1X%f@Gp zqwrGnxf=ByMbVY$^kMY44Bb46GAmJ8DY|nEUD#Yyh@;EUy;^jr47F6F8;8-ADzv=} z9j`>&kD|Ux^tJ?DJ%#$#eXGXbtI(w*=s*R!eFSwLLEny`yXELsB?>)?-jvBKSYbIj zc?=~6N1woX73kwhbo4NauRyI8sIdYy97Dg#QDZs!P=TVV(Y7koS%Mnt(D4<=JMn10 ze}2-HoM`?1Mmt7a^V_xZMl?z;KuIMi@hpnHiW)o6tCuMJ6Y2%@&k@01t$d4JcIVw{ z#$KILxcOlc3eQI22T<8@)YgtZK0HNL|Wb>A0}c&0SVmIku9dTz)@*0b z``esDzU*^OlN@)}w`+C2r|`nLv-ckt;eC;d`#?>Xt{tW>?g#8$R&7K3hiE&l=P!1g zX(8M;Es*j`fOcr+;3Goqi3` zCx4$i{*#jGbo#A><2|d#L))!pJC579(6N4thhu2=631SL9ghD@*yLFMAjZ+9E6y=A zBEj(=#vVtT{dJDxCZ2R`-+S3H^q&%0ztcuKBVA=!eB)QAa3tQOU_LA(J$=}}fBz4} Gu@AHEW-5RH literal 0 HcmV?d00001 diff --git a/pkg/call/voip/media/mlow/lsf_seed.go b/pkg/call/voip/media/mlow/lsf_seed.go new file mode 100644 index 00000000..278ad029 --- /dev/null +++ b/pkg/call/voip/media/mlow/lsf_seed.go @@ -0,0 +1,643 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "bytes" + "compress/zlib" + _ "embed" + "encoding/binary" + "io" + "math" + "sync" +) + +// Build-from-seed for the MLow LSF runtime tables. The expanded LSF tables +// (SmplSynthTables, SmplTables, LsfCb) are the expansion of one small packed ROM +// (lsf_seed.bin), so we store the ROM and rerun the init at load instead of +// committing the pre-expanded f32. The float op order here is load-bearing +// (matmul accumulation, sqrt-then-reciprocal in rotApplyWght, integer truncation +// in lsfDcmfToCmf, scalar unpack8) so the rebuilt tables are bit-faithful. +// +// min_spacing and lsf_extra are NOT separate ROM: min_spacing[v] is min_dist[1-v], +// and lsf_extra is the extra-symbol selector CDF carried in the seed. + +// lsfSeedBlob is the packed LSF ROM (zlib-compressed tables.proto LsfSeed), +// expanded at load — mirrors pitch_seed.bin / cc_seed.bin. +// +//go:embed lsf_seed.bin +var lsfSeedBlob []byte + +const ( + lsfOrder = SmplLPCOrder // 16 + lsfCentroids = LSFCBCentroids + lsfCinvLen = lsfOrder * (lsfOrder + 1) / 2 // 136 + lsfST2Len = 9593 // LSF_ST2_ALL_QLVLS_LEN +) + +// Per-voiced (index 0 = unvoiced, 1 = voiced) scale/min constants. +var ( + lsfCBMin = [2]float32{-0.5873778, -0.24721986} + lsfCBScale = [2]float32{1.3145164e-5, 7.226229e-6} + lsfCinvMin = [2]float32{-3.5960955e-5, -2.778548e-5} + lsfCinvScale = [2]float32{1.8589316e-9, 1.2180106e-9} + lsfRotMin = [2]float32{-0.9124832, -0.8455929} + lsfRotScale = [2]float32{0.006554049, 0.0069253775} + lsfRotCondMin = [2]float32{-0.67291605, -0.8248211} + lsfRotCondScale = [2]float32{0.0052386564, 0.0064186584} +) + +const ( + lsfST2QlvlsMin = float32(-0.45) + lsfST2QlvlsScale = float32(0.0034478905) +) + +// lsfSeed is the packed ROM reshaped into the nested arrays the expansion indexes. +// Outer index [voiced] (0 = unvoiced, 1 = voiced). +type lsfSeed struct { + cb16 [2][lsfCentroids][lsfOrder]uint16 + cinv16 [2][lsfCinvLen]uint16 + rot8 [2][lsfCentroids][lsfOrder][lsfOrder]byte + rotCond8 [2][2][lsfOrder][lsfOrder]byte + mean [2][lsfOrder]float32 + cmf [2][17]uint16 + cmfCond [2][18]uint16 + minDist [2][17]float32 + regCond [2]float32 + minQi [2][2][17][lsfOrder]int8 + maxQi [2][2][17][lsfOrder]int8 + qstep [2][2]float32 + st2Qlvls8 []byte // [9593] + st2Dcmfs []byte // [9593] + lsfSel [3][3]uint16 + lsfExtra [3]uint16 +} + +// decodeVarintsU32 decodes a packed repeated uint32 protobuf field (plain varints). +func decodeVarintsU32(b []byte) []uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L53-L64 + var out []uint32 + i := 0 + for i < len(b) { + var v uint64 + var shift uint + for i < len(b) { + c := b[i] + i++ + v |= uint64(c&0x7f) << shift + if c&0x80 == 0 { + break + } + shift += 7 + } + out = append(out, uint32(v)) + } + return out +} + +// decodeFloats decodes a packed repeated float protobuf field (fixed32 little-endian). +func decodeFloats(b []byte) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L65-L72 + out := make([]float32, len(b)/4) + for i := range out { + out[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[i*4:])) + } + return out +} + +// loadLsfSeed inflates and parses the packed ROM into the nested seed arrays +// (the reference's LsfSeed::reshape, expressed as fixed-shape fills). +func loadLsfSeed() *lsfSeed { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L138-L208 + zr, err := zlib.NewReader(bytes.NewReader(lsfSeedBlob)) + if err != nil { + panic("mlow: inflate lsf seed: " + err.Error()) + } + raw, err := io.ReadAll(zr) + zr.Close() + if err != nil { + panic("mlow: read lsf seed: " + err.Error()) + } + f := parseProto(raw) + s := &lsfSeed{} + + // rot_8 [2][16][16][16] u8 (flat row-major). + p := 0 + for v := 0; v < 2; v++ { + for c := 0; c < lsfCentroids; c++ { + for i := 0; i < lsfOrder; i++ { + for j := 0; j < lsfOrder; j++ { + s.rot8[v][c][i][j] = f[1].bytes[p] + p++ + } + } + } + } + // rot_cond_8 [2][2][16][16] u8. + p = 0 + for v := 0; v < 2; v++ { + for lr := 0; lr < 2; lr++ { + for i := 0; i < lsfOrder; i++ { + for j := 0; j < lsfOrder; j++ { + s.rotCond8[v][lr][i][j] = f[2].bytes[p] + p++ + } + } + } + } + s.st2Qlvls8 = append([]byte(nil), f[3].bytes...) + s.st2Dcmfs = append([]byte(nil), f[4].bytes...) + // st2_min_qi / st2_max_qi [2][2][17][16] i8. + for idx, src := range [][]byte{f[5].bytes, f[6].bytes} { + p = 0 + for v := 0; v < 2; v++ { + for lr := 0; lr < 2; lr++ { + for c := 0; c < 17; c++ { + for i := 0; i < lsfOrder; i++ { + q := int8(src[p]) + if idx == 0 { + s.minQi[v][lr][c][i] = q + } else { + s.maxQi[v][lr][c][i] = q + } + p++ + } + } + } + } + } + // cb_16 [2][16][16] (u32 -> u16). + cb := decodeVarintsU32(f[7].bytes) + p = 0 + for v := 0; v < 2; v++ { + for c := 0; c < lsfCentroids; c++ { + for i := 0; i < lsfOrder; i++ { + s.cb16[v][c][i] = uint16(cb[p]) + p++ + } + } + } + // cinv_16 [2][136]. + cinv := decodeVarintsU32(f[8].bytes) + p = 0 + for v := 0; v < 2; v++ { + for i := 0; i < lsfCinvLen; i++ { + s.cinv16[v][i] = uint16(cinv[p]) + p++ + } + } + // cmf [2][17]. + cmf := decodeVarintsU32(f[9].bytes) + p = 0 + for v := 0; v < 2; v++ { + for i := 0; i < 17; i++ { + s.cmf[v][i] = uint16(cmf[p]) + p++ + } + } + // cmf_cond [2][18]. + cmfc := decodeVarintsU32(f[10].bytes) + p = 0 + for v := 0; v < 2; v++ { + for i := 0; i < 18; i++ { + s.cmfCond[v][i] = uint16(cmfc[p]) + p++ + } + } + // lsf_sel [3][3]. + sel := decodeVarintsU32(f[11].bytes) + p = 0 + for a := 0; a < 3; a++ { + for b := 0; b < 3; b++ { + s.lsfSel[a][b] = uint16(sel[p]) + p++ + } + } + // lsf_extra [3]. + ex := decodeVarintsU32(f[12].bytes) + for i := 0; i < 3; i++ { + s.lsfExtra[i] = uint16(ex[i]) + } + // mean [2][16]. + mean := decodeFloats(f[13].bytes) + p = 0 + for v := 0; v < 2; v++ { + for i := 0; i < lsfOrder; i++ { + s.mean[v][i] = mean[p] + p++ + } + } + // min_dist [2][17]. + md := decodeFloats(f[14].bytes) + p = 0 + for v := 0; v < 2; v++ { + for i := 0; i < 17; i++ { + s.minDist[v][i] = md[p] + p++ + } + } + // reg_cond [2]. + rc := decodeFloats(f[15].bytes) + s.regCond[0], s.regCond[1] = rc[0], rc[1] + // qstep [2][2]. + qs := decodeFloats(f[16].bytes) + p = 0 + for v := 0; v < 2; v++ { + for i := 0; i < 2; i++ { + s.qstep[v][i] = qs[p] + p++ + } + } + return s +} + +// ---- float expansion primitives (op order is load-bearing) ---- + +// lsfMatMultTransp16: transposed 16x16 matrix-vector multiply, +// y[i] = sum_j C[j][i] * x[j] (accumulate seeded at j=0, then += for j>0). +func lsfMatMultTransp16(c *[lsfOrder][lsfOrder]float32, x *[lsfOrder]float32) [lsfOrder]float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L210-L225 + var y [lsfOrder]float32 + x0 := x[0] + for i := 0; i < lsfOrder; i++ { + y[i] = c[0][i] * x0 + } + for j := 1; j < lsfOrder; j++ { + xj := x[j] + for i := 0; i < lsfOrder; i++ { + // Round the product before accumulating: Go would otherwise fuse + // `y[i] + c*xj` into an FMA (one rounding), but the reference rounds + // the multiply and the add separately. + prod := float32(c[j][i] * xj) + y[i] += prod + } + } + return y +} + +// lsfSeedLaroia: Laroia inverse-gap LSF weights, with the gap floored at 1e-3. +func lsfSeedLaroia(lsf *[lsfOrder]float32) [lsfOrder]float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L227-L242 + const minDist = float32(1e-3) + var inv [lsfOrder + 1]float32 + inv[0] = 1.0 / maxF32(lsf[0], minDist) + for i := 1; i < lsfOrder; i++ { + inv[i] = 1.0 / maxF32(lsf[i]-lsf[i-1], minDist) + } + inv[lsfOrder] = 1.0 / maxF32(smplPi-lsf[lsfOrder-1], minDist) + var w [lsfOrder]float32 + for i := 0; i < lsfOrder; i++ { + w[i] = inv[i] + inv[i+1] + } + return w +} + +// lsfRotApplyWght: apply the Laroia weights to the rotation. lsfw = sqrt(laroia(lsf)), +// we[i][j] = rot[i][j]/lsfw[j], wie[j][i] = rot[i][j]*lsfw[j]. +func lsfRotApplyWght(rot *[lsfOrder][lsfOrder]float32, lsf *[lsfOrder]float32) (we, wie [lsfOrder][lsfOrder]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L244-L267 + lsfw := lsfSeedLaroia(lsf) + for i := range lsfw { + lsfw[i] = sqrtF32(lsfw[i]) + } + var lsfwInv [lsfOrder]float32 + for i := 0; i < lsfOrder; i++ { + lsfwInv[i] = 1.0 / lsfw[i] + } + for i := 0; i < lsfOrder; i++ { + for j := 0; j < lsfOrder; j++ { + we[i][j] = rot[i][j] * lsfwInv[j] + wie[j][i] = rot[i][j] * lsfw[j] + } + } + return +} + +// lsfCmfToBits: per-symbol bit cost, bits[i] = -log2f((cmf[i+1]-cmf[i]) / cmf[len-1]). +func lsfCmfToBits(cmf []uint16) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L269-L279 + n := len(cmf) + den := float32(cmf[n-1]) + bits := make([]float32, n-1) + for i := 0; i < n-1; i++ { + num := float32(int32(cmf[i+1]) - int32(cmf[i])) + bits[i] = -log2F32(num / den) + } + return bits +} + +// lsfDcmfToCmf: integer expansion of a delta-CMF to a cumulative u16 CDF of length len+1. +func lsfDcmfToCmf(dcmf []byte) []uint16 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L281-L302 + n := len(dcmf) + cmf := make([]uint16, n+1) + var sum int64 + for i := 0; i < n; i++ { + tmp := int32(dcmf[i]) + 1 + tmp *= tmp + if tmp > 65535 { + tmp = 65535 + } + cmf[i+1] = uint16(tmp) + sum += int64(tmp) + } + cmf[0] = 0 + for i := 1; i < n+1; i++ { + prev := int64(cmf[i-1]) + add := int64(cmf[i])*int64(32767-n)/sum + 1 + cmf[i] = uint16(prev + add) + } + return cmf +} + +// lsfUnpack8: out[i][j] = min + packed[i][j]*scale, scalar. +func lsfUnpack8(packed *[lsfOrder][lsfOrder]byte, scale, min float32) [lsfOrder][lsfOrder]float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L304-L312 + var out [lsfOrder][lsfOrder]float32 + for i := 0; i < lsfOrder; i++ { + for j := 0; j < lsfOrder; j++ { + // Round packed*scale before adding min (defeat FMA fusion; reference rounds separately). + prod := float32(float32(packed[i][j]) * scale) + out[i][j] = min + prod + } + } + return out +} + +// ---- small slice converters ---- + +func arr16ToSlice(a *[lsfOrder]float32) []float32 { + out := make([]float32, lsfOrder) + copy(out, a[:]) + return out +} + +func mat16ToSlice(m *[lsfOrder][lsfOrder]float32) [][]float32 { + out := make([][]float32, lsfOrder) + for i := range out { + out[i] = arr16ToSlice(&m[i]) + } + return out +} + +// lsfBuilt holds the three LSF runtime structs rebuilt from one seed. +type lsfBuilt struct { + synth *SmplSynthTables + tables *SmplTables + cb *LsfCb +} + +// buildLsfFromSeed runs the LSF codebook expansion to produce all three runtime structs. +func buildLsfFromSeed(s *lsfSeed) *lsfBuilt { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L314-L401 + st1 := make([]st1Tables, 0, 2) + // Decoder-side accumulators for SmplSynthTables (centroids/matrices = cbhalf/we). + synthCentroids := make([][][]float32, 2) + synthMatrices := make([][][][]float32, 2) + // grid==16 decorr matrices: the same Rotcond unpack8(rot_cond_8), flattened [lr][256]. + synthGrid16Matrices := make([][][]float32, 2) + + for voiced := 0; voiced < 2; voiced++ { + // cInv (symmetric lower-triangular fill). + var cInv [lsfOrder][lsfOrder]float32 + p := 0 + for i := 0; i < lsfOrder; i++ { + for j := 0; j <= i; j++ { + // Round scale*cinv before adding min (defeat FMA fusion). + prod := float32(lsfCinvScale[voiced] * float32(s.cinv16[voiced][p])) + v := lsfCinvMin[voiced] + prod + cInv[i][j] = v + cInv[j][i] = v + p++ + } + } + + var cbhalf [lsfCentroids][lsfOrder]float32 + var cbCinv [lsfCentroids][lsfOrder]float32 + var we [lsfCentroids][lsfOrder][lsfOrder]float32 + var wie [lsfCentroids][lsfOrder][lsfOrder]float32 + for c := 0; c < lsfCentroids; c++ { + var lsfCB [lsfOrder]float32 + for i := 0; i < lsfOrder; i++ { + // Round cb16*scale before the additions (defeat FMA fusion of min + cb16*scale). + prod := float32(float32(s.cb16[voiced][c][i]) * lsfCBScale[voiced]) + lsfCB[i] = lsfCBMin[voiced] + prod + s.mean[voiced][i] + cbhalf[c][i] = lsfCB[i] * 0.5 + } + cbCinv[c] = lsfMatMultTransp16(&cInv, &lsfCB) + rot := lsfUnpack8(&s.rot8[voiced][c], lsfRotScale[voiced], lsfRotMin[voiced]) + weC, wieC := lsfRotApplyWght(&rot, &lsfCB) + we[c] = weC + wie[c] = wieC + } + + // Rotcond[lowRate] = unpack8(rot_cond_8[lowRate]). + var rotcond [2][lsfOrder][lsfOrder]float32 + for lr := 0; lr < 2; lr++ { + rotcond[lr] = lsfUnpack8(&s.rotCond8[voiced][lr], lsfRotCondScale[voiced], lsfRotCondMin[voiced]) + } + + bits := lsfCmfToBits(s.cmf[voiced][:]) // 16 + bitsCond := lsfCmfToBits(s.cmfCond[voiced][:]) // 17 + + t := st1Tables{ + Cbhalf: make([][]float32, lsfCentroids), + CInv: mat16ToSlice(&cInv), + BitsCond: bitsCond, + Rotcond: [][][]float32{mat16ToSlice(&rotcond[0]), mat16ToSlice(&rotcond[1])}, + CbCinv: make([][]float32, lsfCentroids), + We: make([][][]float32, lsfCentroids), + Bits: bits, + Wie: make([][][]float32, lsfCentroids), + } + for c := 0; c < lsfCentroids; c++ { + t.Cbhalf[c] = arr16ToSlice(&cbhalf[c]) + t.CbCinv[c] = arr16ToSlice(&cbCinv[c]) + t.We[c] = mat16ToSlice(&we[c]) + t.Wie[c] = mat16ToSlice(&wie[c]) + } + st1 = append(st1, t) + + // SmplSynthTables decoder centroids/matrices: grid g<16 == cbhalf[g]/we[g]. The + // grid==16 row is never read (grid==16 returns before indexing it), so not appended. + sc := make([][]float32, lsfCentroids) + sm := make([][][]float32, lsfCentroids) + for g := 0; g < lsfCentroids; g++ { + sc[g] = arr16ToSlice(&cbhalf[g]) + sm[g] = mat16ToSlice(&we[g]) + } + synthCentroids[voiced] = sc + synthMatrices[voiced] = sm + // grid16_matrices[voiced][lr] = the Rotcond computed above, flattened row-major to 256. + g16 := make([][]float32, 2) + for lr := 0; lr < 2; lr++ { + flat := make([]float32, 0, lsfOrder*lsfOrder) + for i := 0; i < lsfOrder; i++ { + flat = append(flat, rotcond[lr][i][:]...) + } + g16[lr] = flat + } + synthGrid16Matrices[voiced] = g16 + } + + // Stage 2: the flat QlvlsTable / cmfTable / numBitsTable walks. + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L403-L480 + qlvlsFlat := make([]float32, lsfST2Len) + var numqlvlsFlat [2][2][17][lsfOrder]int32 + var qoffFlat [2][2][17][lsfOrder]int + var cmfSlices [2][2][17][lsfOrder][]uint16 + var numbitsSlices [2][2][17][lsfOrder][]float32 + + qPtr, q8Ptr, dcmfPtr := 0, 0, 0 + for voiced := 0; voiced < 2; voiced++ { + for lr := 0; lr < 2; lr++ { + for c := 0; c < lsfCentroids+1; c++ { + qstep := s.qstep[voiced][lr] + if c == lsfCentroids { + qstep *= lsfQstepCondMult + } + for i := 0; i < lsfOrder; i++ { + minQi := int32(s.minQi[voiced][lr][c][i]) + maxQi := int32(s.maxQi[voiced][lr][c][i]) + numQlvls := int(maxQi - minQi + 1) + numqlvlsFlat[voiced][lr][c][i] = int32(numQlvls) + qoffFlat[voiced][lr][c][i] = qPtr + for lvl := 0; lvl < numQlvls; lvl++ { + q8 := float32(s.st2Qlvls8[q8Ptr]) + // Round scale*q8 before adding min (defeat FMA fusion). + prod := float32(lsfST2QlvlsScale * q8) + qlvlsFlat[qPtr] = (lsfST2QlvlsMin + prod + + float32(lvl) + float32(minQi)) * qstep + qPtr++ + q8Ptr++ + } + dcmf := s.st2Dcmfs[dcmfPtr : dcmfPtr+numQlvls] + cmf := lsfDcmfToCmf(dcmf) // numQlvls+1 + nb := lsfCmfToBits(cmf) // numQlvls + dcmfPtr += numQlvls + cmfSlices[voiced][lr][c][i] = cmf + numbitsSlices[voiced][lr][c][i] = nb + } + } + } + } + if qPtr != lsfST2Len || q8Ptr != lsfST2Len || dcmfPtr != lsfST2Len { + panic("mlow: lsf seed stage-2 pointer miscount (corrupt seed)") + } + + // Assemble st2 (LsfCb) and valtables / lsf_stage2 (sliced from the flat tables). + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L482-L529 + st2 := make([][][]st2Tables, 2) + valtables := make([][][][][]float32, 2) + lsfStage2 := make([][][][][]uint16, 2) + for voiced := 0; voiced < 2; voiced++ { + st2[voiced] = make([][]st2Tables, 2) + valtables[voiced] = make([][][][]float32, 2) + lsfStage2[voiced] = make([][][][]uint16, 2) + for lr := 0; lr < 2; lr++ { + st2[voiced][lr] = make([]st2Tables, lsfCentroids+1) + valtables[voiced][lr] = make([][][]float32, lsfCentroids+1) + lsfStage2[voiced][lr] = make([][][]uint16, lsfCentroids+1) + for c := 0; c < lsfCentroids+1; c++ { + nq := make([]int32, lsfOrder) + qlvls := make([][]float32, lsfOrder) + vt := make([][]float32, lsfOrder) + nb := make([][]float32, lsfOrder) + cmfRows := make([][]uint16, lsfOrder) + for i := 0; i < lsfOrder; i++ { + n := int(numqlvlsFlat[voiced][lr][c][i]) + off := qoffFlat[voiced][lr][c][i] + slice := append([]float32(nil), qlvlsFlat[off:off+n]...) + nq[i] = int32(n) + qlvls[i] = slice + vt[i] = append([]float32(nil), qlvlsFlat[off:off+n]...) + nb[i] = numbitsSlices[voiced][lr][c][i] + cmfRows[i] = cmfSlices[voiced][lr][c][i] + } + st2[voiced][lr][c] = st2Tables{NumQlvls: nq, Qlvls: qlvls, NumBits: nb} + valtables[voiced][lr][c] = vt + lsfStage2[voiced][lr][c] = cmfRows + } + } + } + + // Assemble the runtime structs. + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L531-L578 + cb := &LsfCb{ + St1: st1, + St2: st2, + MinQi: lsfCloneQi(&s.minQi), + MaxQi: lsfCloneQi(&s.maxQi), + Qstep: [][]float32{{s.qstep[0][0], s.qstep[0][1]}, {s.qstep[1][0], s.qstep[1][1]}}, + MeanV: arr16ToSlice(&s.mean[1]), + MeanUV: arr16ToSlice(&s.mean[0]), + RegCond: []float32{s.regCond[0], s.regCond[1]}, + MinDistV: append([]float32(nil), s.minDist[1][:]...), + MinDistUV: append([]float32(nil), s.minDist[0][:]...), + } + + tables := &SmplTables{ + LsfSel: [][]uint16{ + {s.lsfSel[0][0], s.lsfSel[0][1], s.lsfSel[0][2]}, + {s.lsfSel[1][0], s.lsfSel[1][1], s.lsfSel[1][2]}, + {s.lsfSel[2][0], s.lsfSel[2][1], s.lsfSel[2][2]}, + }, + LsfGrid: LsfGrid{ + // match1 = CMF_cond_v, match1_alt = CMF_cond_uv, match0 = CMF_uv, match0_alt = CMF_v. + Match1: append([]uint16(nil), s.cmfCond[1][:]...), + Match1Alt: append([]uint16(nil), s.cmfCond[0][:]...), + Match0: append([]uint16(nil), s.cmf[0][:]...), + Match0Alt: append([]uint16(nil), s.cmf[1][:]...), + }, + LsfStage2: lsfStage2, + LsfExtra: []uint16{s.lsfExtra[0], s.lsfExtra[1], s.lsfExtra[2]}, + } + + synth := &SmplSynthTables{ + Valtables: valtables, + Centroids: synthCentroids, + Matrices: synthMatrices, + // min_spacing[v] = min_dist[1-v] (the index swap), not separate ROM. + MinSpacing: [][]float32{append([]float32(nil), s.minDist[1][:]...), append([]float32(nil), s.minDist[0][:]...)}, + // grid16_w[v] = mean[1-v] (the 1-v swap bakes in the synth's INVERTED selection); + // grid16_alpha = reg_cond; grid16_matrices = unpack8(rot_cond_8) computed above. + Grid16W: [][]float32{arr16ToSlice(&s.mean[1]), arr16ToSlice(&s.mean[0])}, + Grid16Alpha: []float32{s.regCond[0], s.regCond[1]}, + Grid16Matrices: synthGrid16Matrices, + } + + return &lsfBuilt{synth: synth, tables: tables, cb: cb} +} + +// lsfCloneQi widens the i8 stage-2 qi bounds to the [2][2][17][16]int32 runtime shape. +func lsfCloneQi(qi *[2][2][17][lsfOrder]int8) [][][][]int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L581-L593 + out := make([][][][]int32, 2) + for v := 0; v < 2; v++ { + out[v] = make([][][]int32, 2) + for lr := 0; lr < 2; lr++ { + out[v][lr] = make([][]int32, 17) + for c := 0; c < 17; c++ { + row := make([]int32, lsfOrder) + for i := 0; i < lsfOrder; i++ { + row[i] = int32(qi[v][lr][c][i]) + } + out[v][lr][c] = row + } + } + } + return out +} + +var ( + lsfBuiltOnce sync.Once + lsfBuiltVal *lsfBuilt +) + +// loadLsfBuilt loads the LSF seed ROM and builds all three runtime structs once. +func loadLsfBuilt() *lsfBuilt { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L595-L604 + lsfBuiltOnce.Do(func() { + lsfBuiltVal = buildLsfFromSeed(loadLsfSeed()) + }) + return lsfBuiltVal +} diff --git a/pkg/call/voip/media/mlow/mem.go b/pkg/call/voip/media/mlow/mem.go new file mode 100644 index 00000000..bfcde129 --- /dev/null +++ b/pkg/call/voip/media/mlow/mem.go @@ -0,0 +1,214 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "encoding/binary" + "sync" +) + +type smplMemRegion struct { + base uint32 + data []byte +} + +// SmplMem is an embedded window of the codec's heap holding the runtime-built CDF +// tables, plus the table-base pointers, so the decode paths can replicate the +// original pointer arithmetic exactly. +type SmplMem struct { + regions []smplMemRegion + GCC uint32 + GNrg uint32 + GPitch uint32 + GClk uint32 +} + +// Fixed WASM-build globals for the Group-D heap layout (smpl_mem.rs). The window is +// built at these absolute addresses so the pitch lag/contour pointer-chase lands +// unchanged. +const ( + memGClk = 0xb9f9a8 + memGPitch = 0xb9d378 + memPcfg = memGClk + 0x5704 + memHdrContourMap = 0xe7c10 + memHdrLagCdf = 0xbaa7b0 + memHdrFracBase = 0xbaa9be + memHdrDeltaCdf = 0xbab13e + memDeltaBounds = 0xe7ef0 + memNumContours = 217 +) + +var memHdrUnused = [3]uint32{0xe7d20, 0xe7ef0, 0xe8096} + +func u16Bytes(v []uint32) []byte { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_mem.rs#L129-L130 + b := make([]byte, len(v)*2) + for i, x := range v { + binary.LittleEndian.PutUint16(b[2*i:], uint16(x)) + } + return b +} + +// buildSmplMemFromSeed builds the pitch lag/contour (Group D) heap window from the +// pitch seed (port of smpl_mem.rs build_smpl_mem), reproducing the carved window +// byte-for-byte at every address the consumer reads. Groups A/B/C/E moved to the +// logical CcTables, so GCC/GNrg are 0 here. +func buildSmplMemFromSeed() *SmplMem { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_mem.rs#L90-L156 + w := buildContourWindow() + var regions []smplMemRegion + push := func(base uint32, data []byte) { regions = append(regions, smplMemRegion{base: base, data: data}) } + + var r0 []byte + put32 := func(x int32) { + var b [4]byte + binary.LittleEndian.PutUint32(b[:], uint32(x)) + r0 = append(r0, b[:]...) + } + for _, rec := range w.records { + blocks, seglens := rec[0], rec[1] + for i := 0; i < 8; i++ { + v := 0 + if i < len(blocks) { + v = blocks[i] + } + put32(int32(v)) + } + for i := 0; i < 8; i++ { + v := 0 + if i < len(seglens) { + v = seglens[i] + } + put32(int32(v)) + } + put32(int32(len(blocks))) + } + put32(187) // NUM_BLOCKTRACKS gap + for _, h := range []uint32{memNumContours, memHdrContourMap, memHdrLagCdf, memHdrFracBase, memHdrUnused[0], memHdrUnused[1], memHdrUnused[2], memHdrDeltaCdf} { + var b [4]byte + binary.LittleEndian.PutUint32(b[:], h) + r0 = append(r0, b[:]...) + } + push(memPcfg+0x1d38, r0) + + push(memHdrLagCdf, u16Bytes(w.lagCdf)) + var frac []uint32 + for _, c := range w.fracCmfs { + frac = append(frac, c...) + } + push(memHdrFracBase, u16Bytes(frac)) + var delta []uint32 + for _, c := range w.deltaCmfs { + delta = append(delta, c...) + } + push(memHdrDeltaCdf, u16Bytes(delta)) + push(memHdrContourMap, append([]byte(nil), w.contourMap...)) + + bounds := make([]byte, 0, len(w.firstblockRange)*2+2) + for _, p := range w.firstblockRange { + bounds = append(bounds, byte(p[0]), byte(p[1])) + } + bounds = append(bounds, 0, 0) + push(memDeltaBounds, bounds) + + return &SmplMem{regions: regions, GCC: 0, GNrg: 0, GPitch: memGPitch, GClk: memGClk} +} + +var ( + smplMemOnce sync.Once + smplMem *SmplMem +) + +// LoadSmplMem builds the pitch lag/contour (Group D) heap window from the pitch seed +// once and returns the shared, read-only window. Groups A/B/C/E moved to the logical +// CcTables (cc_tables.go), so this no longer reads a cc_blob snapshot. +func LoadSmplMem() *SmplMem { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_mem.rs#L158-L160 + smplMemOnce.Do(func() { smplMem = buildSmplMemFromSeed() }) + return smplMem +} + +// regionFor returns the region data containing [addr, addr+n) and the byte offset +// of addr within it. ok is false when no region covers the range. +func (m *SmplMem) regionFor(addr uint32, n int) (data []byte, off int, ok bool) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L79-L86 + for _, r := range m.regions { + if addr >= r.base && int(addr-r.base)+n <= len(r.data) { + return r.data, int(addr - r.base), true + } + } + return nil, 0, false +} + +// U8 reads one byte at addr, or 0 if addr is outside every region. +func (m *SmplMem) U8(addr uint32) uint8 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L88-L90 + if data, off, ok := m.regionFor(addr, 1); ok { + return data[off] + } + return 0 +} + +// U16 reads a little-endian uint16 at addr, or 0 if out of region. +func (m *SmplMem) U16(addr uint32) uint16 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L92-L95 + if data, off, ok := m.regionFor(addr, 2); ok { + return binary.LittleEndian.Uint16(data[off:]) + } + return 0 +} + +// I16 is the signed reinterpretation of U16. +func (m *SmplMem) I16(addr uint32) int16 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L97-L99 + return int16(m.U16(addr)) +} + +// U32 reads a little-endian uint32 at addr, or 0 if out of region. +func (m *SmplMem) U32(addr uint32) uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L101-L105 + if data, off, ok := m.regionFor(addr, 4); ok { + return binary.LittleEndian.Uint32(data[off:]) + } + return 0 +} + +// I32 is the signed reinterpretation of U32. +func (m *SmplMem) I32(addr uint32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L107-L109 + return int32(m.U32(addr)) +} + +// CDFAt materializes the n-entry cumulative uint16 CDF at addr; entries outside +// the window read as 0. +func (m *SmplMem) CDFAt(addr uint32, n int) []uint16 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L113-L117 + out := make([]uint16, n) + for i := range n { + out[i] = m.U16(addr + uint32(i)*2) + } + return out +} + +// silkLSFCosTabFIXQ12 is the Q12 cosine approximation table (129 entries, +// symmetric around index 64) for the LSF root search. +// +// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/silk_lsf_cos_tab.rs#L4-L22 +var silkLSFCosTabFIXQ12 = [129]int32{ + 8192, 8190, 8182, 8170, 8152, 8130, 8104, 8072, + 8034, 7994, 7946, 7896, 7840, 7778, 7714, 7644, + 7568, 7490, 7406, 7318, 7226, 7128, 7026, 6922, + 6812, 6698, 6580, 6458, 6332, 6204, 6070, 5934, + 5792, 5648, 5502, 5352, 5198, 5040, 4880, 4718, + 4552, 4382, 4212, 4038, 3862, 3684, 3502, 3320, + 3136, 2948, 2760, 2570, 2378, 2186, 1990, 1794, + 1598, 1400, 1202, 1002, 802, 602, 402, 202, + 0, -202, -402, -602, -802, -1002, -1202, -1400, + -1598, -1794, -1990, -2186, -2378, -2570, -2760, -2948, + -3136, -3320, -3502, -3684, -3862, -4038, -4212, -4382, + -4552, -4718, -4880, -5040, -5198, -5352, -5502, -5648, + -5792, -5934, -6070, -6204, -6332, -6458, -6580, -6698, + -6812, -6922, -7026, -7128, -7226, -7318, -7406, -7490, + -7568, -7644, -7714, -7778, -7840, -7896, -7946, -7994, + -8034, -8072, -8104, -8130, -8152, -8170, -8182, -8190, + -8192, +} diff --git a/pkg/call/voip/media/mlow/noise.go b/pkg/call/voip/media/mlow/noise.go new file mode 100644 index 00000000..36c6fffc --- /dev/null +++ b/pkg/call/voip/media/mlow/noise.go @@ -0,0 +1,528 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "math" + "sync" +) + +// CELP decoder-side noise generator: builds the shaped residual noise the CELP +// synthesis mixes into the excitation (smpl_gennoise.rs). The perceptual-weighting +// front-end and bitrate controller the datasheet also bundles are encoder/analysis +// concerns and are scaffolded with the encoder module, not here. + +const ( + smplMaxSFLen = 160 + smplNoiseCorrOrder = 2 + smplNoiseDCTOrder = 16 + smplCelpFsKHz = 16 + smplPiNoise = float32(3.1415926535897) + + decNoiseVNoiseGain = float32(0.35) + decNoiseUVNoiseGain = float32(0.8) + decNoiseUVFcornerHz = float32(800.0) + envSmthCoefV = float32(0.95) + envSmthCoefUV = float32(0.995) + envSmthCoefUVV = float32(0.99) +) + +var coefMAV = [3]float32{0.25, -0.496, 0.25} + +// NoiseGenerator is the persistent decoder-side noise generator state. +type NoiseGenerator struct { + EnvSmth float32 + EnvLast float32 + OutStateUV [2]float32 + OutStateV [2]float32 + CorrSmth [smplNoiseCorrOrder + 1]float32 + ShapeState [smplNoiseCorrOrder]float32 + PrevVoiced bool + SinceUnvoiced int32 + RandSeed int32 +} + +// NewNoiseGenerator allocates a zeroed noise generator. +func NewNoiseGenerator() *NoiseGenerator { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gennoise.rs#L359-L374 + return &NoiseGenerator{} +} + +// smpl_RAND: LCG, wrapping i32 arithmetic (907633515 + (u32)seed*196314165). +func smplRand(seed int32) int32 { + return int32(907633515) + int32(uint32(seed)*196314165) +} + +// smpl_sigmoid with the same +/-80 clamp as C. +func smplSigmoid(x float32) float32 { + if x > 80.0 { + return 1.0 + } + if x < -80.0 { + return 0.0 + } + return 1.0 / (1.0 + float32(math.Exp(float64(-x)))) +} + +func smplNrg(x []float32) float32 { + var nrg float32 + for _, v := range x { + nrg += v * v + } + return nrg +} + +func smplSum(x []float32) float32 { + var s float32 + for _, v := range x { + s += v + } + return s +} + +func smplMaximum(x []float32) float32 { + m := x[0] + for _, v := range x[1:] { + if v > m { + m = v + } + } + return m +} + +// smpl_gen_rand_pulses: 4-at-a-time bit-rotated white pulses scaled by 8.1e-10. +func smplGenRandPulses(noise []float32, l int, seed *int32) { + const sc = float32(8.1e-10) + i := 0 + for i+3 < l { + *seed = smplRand(*seed) + s := uint32(*seed) + noise[i] = sc * float32(*seed) + noise[i+1] = sc * float32(int32(s<<8)) + noise[i+2] = sc * float32(int32(s<<16)) + noise[i+3] = sc * float32(int32(s<<24)) + i += 4 + } + for i < l { + *seed = smplRand(*seed) + noise[i] = sc * float32(*seed) + i++ + } +} + +// smpl_get_env: squared-signal smoothing envelope (4-wide, mirrors the C order). +func smplGetEnv(exc []float32, length int, smthCoef float32, smthState *float32, env []float32) { + smthCoef *= smthCoef // operate on squared signal + state := *smthState + 1e-8 + state *= state + gainCoef := 1.0 - smthCoef + smthCoef2 := smthCoef * smthCoef + gainSmthCoef := gainCoef * smthCoef + i := 0 + for i+3 < length { + tmp0 := float32(exc[i]*exc[i]) + float32(exc[i+1]*exc[i+1]) + tmp1 := float32(exc[i+2]*exc[i+2]) + float32(exc[i+3]*exc[i+3]) + y1 := float32(gainCoef*tmp1) + float32(gainSmthCoef*tmp0) + float32(smthCoef2*state) + y0 := float32(gainCoef*tmp0) + float32(smthCoef*state) + env[i] = float32(math.Sqrt(float64(y0))) + env[i+1] = env[i] + env[i+2] = float32(math.Sqrt(float64(y1))) + env[i+3] = env[i+2] + state = y1 + i += 4 + } + *smthState = env[length-1] +} + +// smpl_get_env0: decaying envelope when there is no excitation to seed from. +func smplGetEnv0(length int, smthCoef float32, smthState *float32, env []float32) { + smthCoef2 := smthCoef * smthCoef + env[0] = (*smthState + 1e-8) * smthCoef + env[1] = env[0] + i := 2 + for i+2 < length { + env[i+2] = env[i-1] * smthCoef2 + env[i+3] = env[i+2] + env[i] = env[i-1] * smthCoef + env[i+1] = env[i] + i += 4 + } + env[length-2] = env[length-3] * smthCoef + env[length-1] = env[length-2] + *smthState = env[length-1] +} + +// smpl_filt_ma1 (coef_len=2, state_len=1). x != y. +func smplFiltMA1(x []float32, n int, coef [2]float32, state *float32, y []float32) { + if coef[0] == 1.0 { + for k := 1; k < n; k++ { + y[k] = x[k] + coef[1]*x[k-1] + } + } else { + for k := 0; k < n; k++ { + y[k] = coef[0] * x[k] + } + for k := 1; k < n; k++ { + y[k] += coef[1] * x[k-1] + } + } + y[0] = coef[0]*x[0] + coef[1]*(*state) + *state = x[n-1] +} + +// smpl_filt_ar1 (coef_len=2, state_len=1, coef[0]==1). +func smplFiltAR1(x []float32, n int, coef [2]float32, state *float32, y []float32) { + ar1 := -coef[1] + ytmp := *state + for nn := 0; nn < n; nn++ { + ytmp = x[nn] + ytmp*ar1 + y[nn] = ytmp + } + *state = ytmp +} + +// smpl_filt_arma1: MA1 then AR1, state {ma, ar}. +func smplFiltARMA1(x []float32, n int, coefMA, coefAR [2]float32, state *[2]float32, y []float32) { + var tmp [smplMaxSFLen]float32 + maState := state[0] + smplFiltMA1(x, n, coefMA, &maState, tmp[:]) + state[0] = maState + arState := state[1] + smplFiltAR1(tmp[:], n, coefAR, &arState, y) + state[1] = arState +} + +// smpl_filt_ma2 (coef_len=3, state_len=2). x != y. +func smplFiltMA2(x []float32, n int, coef [3]float32, state *[2]float32, y []float32) { + if coef[0] == 1.0 { + for i := 1; i < n; i++ { + y[i] = x[i] + coef[1]*x[i-1] + } + } else { + for i := 0; i < n; i++ { + y[i] = coef[0] * x[i] + } + for i := 1; i < n; i++ { + y[i] += coef[1] * x[i-1] + } + } + for i := 2; i < n; i++ { + y[i] += coef[2] * x[i-2] + } + y[0] = coef[0]*x[0] + coef[1]*state[0] + coef[2]*state[1] + y[1] += coef[2] * state[0] + state[0] = x[n-1] + state[1] = x[n-2] +} + +// smpl_spec_fact2: spectral factorization of a 3-tap autocorrelation into a 3-tap MA. +func smplSpecFact2(cIn [3]float32, a *[3]float32) { + c := cIn + c[0] += 1e-30 + invC0 := 1.0 / c[0] + r2 := c[2] * invC0 + r1 := c[1] / (c[0] * (1.0 + r2)) + for iter := 0; iter < 2; iter++ { + v0 := 1.0 + r1*r1 + r2*r2 + v1 := r1 + r1*r2 + s := -2.0 / v0 + da0 := s * r1 + da1 := s * r2 + s = v0 * invC0 + e1 := s*c[1] - v1 + e2 := s*c[2] - r2 + r0 := 2.0*r1 + v0*da0 + r3 := 2.0*r2 + v0*da1 + rr00 := r0 * r0 + rr01 := r0 * r3 + rr11 := r3 * r3 + rcap1 := 1.0 + r2 + v1*da0 + r4 := r1 + v1*da1 + rr00 += rcap1 * rcap1 + rr01 += rcap1 * r4 + rr11 += r4 * r4 + re0 := rcap1 * e1 + re1 := r4 * e1 + r2c := r2 * da0 + r5 := 1.0 + r2*da1 + rr00 += r2c * r2c + rr01 += r2c * r5 + rr11 += r5 * r5 + re0 += r2c * e2 + re1 += r5 * e2 + s = rr00*rr11 - rr01*rr01 + if s < 1e-4 { + break + } + s = 1.0 / s + r1 += (rr11*re0 - rr01*re1) * s + r2 += (-rr01*re0 + rr00*re1) * s + } + sc := float32(math.Sqrt(float64(c[0] / (1.0 + r1*r1 + r2*r2)))) + a[0] = sc + a[1] = sc * r1 + a[2] = sc * r2 +} + +// noiseDCT builds the noise DCT matrix (dct_mat_t[CORR+1][DCT_ORDER]), once. +func noiseDCT() *[smplNoiseCorrOrder + 1][smplNoiseDCTOrder]float32 { + noiseDCTOnce.Do(func() { + sc := 1.0 / float32(math.Sqrt(float64(smplNoiseDCTOrder))) + for i := 0; i < smplNoiseDCTOrder; i++ { + dOmega := ((0.5 + float32(i)) * smplPiNoise) / float32(smplNoiseDCTOrder) + var omega float32 + for j := 0; j < smplNoiseCorrOrder+1; j++ { + noiseDCTMat[j][i] = float32(math.Cos(float64(omega))) * sc + omega += dOmega + } + } + }) + return &noiseDCTMat +} + +var ( + noiseDCTOnce sync.Once + noiseDCTMat [smplNoiseCorrOrder + 1][smplNoiseDCTOrder]float32 +) + +// noiseMatMultTransp16: y[0..16] = sum_j C[j][i]*x[j]. +func noiseMatMultTransp16(c *[smplNoiseCorrOrder + 1][smplNoiseDCTOrder]float32, x, y []float32, lenX int) { + var yt [smplNoiseDCTOrder]float32 + xtmp := x[0] + for i := 0; i < smplNoiseDCTOrder; i++ { + yt[i] = c[0][i] * xtmp + } + for j := 1; j < lenX; j++ { + xt := x[j] + for i := 0; i < smplNoiseDCTOrder; i++ { + yt[i] += c[j][i] * xt + } + } + copy(y[:smplNoiseDCTOrder], yt[:]) +} + +// noiseMatMult: y[i] = dot(C[i], x) over DCT_ORDER, for i in 0..CORR+1. +func noiseMatMult(c *[smplNoiseCorrOrder + 1][smplNoiseDCTOrder]float32, x, y []float32) { + for i := 0; i < smplNoiseCorrOrder+1; i++ { + var acc float32 + for k := 0; k < smplNoiseDCTOrder; k++ { + acc += c[i][k] * x[k] + } + y[i] = acc + } +} + +// SmplGetNormalizedBitrate maps the per-frame pulse count to the normalized bitrate. +func SmplGetNormalizedBitrate(numPulses, frameLength16 int32) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gennoise.rs#L329-L332 + pulsesPer20ms := float32(numPulses*frameLength16) / (20.0 * 16.0) + return smplSigmoid(1.4*float32(math.Log2(float64(pulsesPer20ms+1.0))) - 6.5) +} + +// SmplDecodeResnrg maps the quantized residual-energy floor to a linear residual energy. +func SmplDecodeResnrg(nrgresFrameDbqQ14, fcbSubfrlen int32) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gennoise.rs#L336-L343 + exp := 0.1 * (float32(nrgresFrameDbqQ14) / float32(int32(1)<<14)) + resnrg := float32(math.Pow(10, float64(exp))) - smplResNrgBias + if resnrg < 0.0 { + resnrg = 0.0 + } + return resnrg * float32(fcbSubfrlen) +} + +// add_noise_uv: HP-shape the unvoiced noise and add it into noise. +func addNoiseUV(ng *NoiseGenerator, excNoiseUV []float32, l int, lsf []float32, nrgRatio float32, noise []float32) { + lsfHz := 16000.0 * (lsf[0] + lsf[1]) / (4.0 * smplPiNoise) + minUVFcornerHz := lsfHz * 3.0 * smplSigmoid(0.2/(lsf[1]-lsf[0]+1e-30)-3.0) + uvFcornerHz := decNoiseUVFcornerHz * minF32(0.6+0.4*nrgRatio, 1.0) + uvFcornerHz = maxF32(uvFcornerHz, minUVFcornerHz) + uvFcornerHz = minF32(uvFcornerHz, 1500.0) + coefTmp := 6.0 * uvFcornerHz / 16000.0 + g := (1.0 - 0.5*coefTmp) * decNoiseUVNoiseGain + coefMAUV := [2]float32{g, -g} + coefARUV := [2]float32{1.0, -1.0 + coefTmp} + var filtered [smplMaxSFLen]float32 + smplFiltARMA1(excNoiseUV, l, coefMAUV, coefARUV, &ng.OutStateUV, filtered[:]) + copy(excNoiseUV[:l], filtered[:l]) + for i := 0; i < l; i++ { + noise[i] += excNoiseUV[i] + } +} + +func minF32(a, b float32) float32 { + if a < b { + return a + } + return b +} + +// SmplCelpGenNoise builds the shaped residual noise for one subframe (writes l +// samples into noise). +func SmplCelpGenNoise(ng *NoiseGenerator, excLpc []float32, l int, voiced bool, numPulses int32, nrgres float32, fcbgIdx int32, lsf []float32, normalizedBitrate float32, fcbgainsUV []float32, noise []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gennoise.rs#L416-L611 + nrgRatio := float32(1.0) + var noiseUV, noiseV, noiseV2, env [smplMaxSFLen]float32 + + if voiced { + var corrs, c, ctgt [smplNoiseCorrOrder + 1]float32 + for i := 0; i < smplNoiseCorrOrder+1; i++ { + var acc float32 + for k := 0; k < l-i; k++ { + acc += excLpc[k] * excLpc[k+i] + } + corrs[i] = acc + } + corrs[0] += 1e-12 + corrSmthCoef := float32(0.16) + if l == smplCelpFsKHz*10 { + corrSmthCoef = 0.4 + } + for i := 0; i < smplNoiseCorrOrder+1; i++ { + ng.CorrSmth[i] += corrSmthCoef * (corrs[i] - ng.CorrSmth[i]) + } + scale := decNoiseVNoiseGain * decNoiseVNoiseGain * corrs[0] / ng.CorrSmth[0] + for i := 0; i < smplNoiseCorrOrder+1; i++ { + c[i] = ng.CorrSmth[i] * scale + } + c[1] *= 2.0 + c[2] *= 2.0 + + dct := noiseDCT() + var f2, f2Tgt [smplNoiseDCTOrder]float32 + noiseMatMultTransp16(dct, c[:], f2[:], smplNoiseCorrOrder+1) + m := smplMaximum(f2[:smplNoiseDCTOrder]) * 1.5 + for i := 0; i < smplNoiseDCTOrder; i++ { + f2Tgt[i] = m - f2[i] + } + noiseMatMult(dct, f2Tgt[:], ctgt[:]) + smplGenRandPulses(noiseV[:], l, &ng.RandSeed) + if !ng.PrevVoiced { + ng.EnvSmth = ng.EnvLast + } + smplGetEnv(excLpc, l, envSmthCoefV, &ng.EnvSmth, env[:]) + for i := 0; i < l; i++ { + noiseV[i] *= env[i] + } + nrgNoise := smplNrg(noiseV[:l]) + inv := 1.0 / (nrgNoise + 1e-12) + for i := 0; i < smplNoiseCorrOrder+1; i++ { + ctgt[i] *= inv + } + var coefMA [smplNoiseCorrOrder + 1]float32 + smplSpecFact2(ctgt, &coefMA) + smplFiltMA2(noiseV[:], l, coefMA, &ng.ShapeState, noiseV2[:]) + + if !ng.PrevVoiced { + smplGenRandPulses(noiseUV[:], l, &ng.RandSeed) + envVal := ng.EnvLast * envSmthCoefUVV + for i := 0; i < l; i += 2 { + noiseUV[i] *= envVal + noiseUV[i+1] *= envVal * envSmthCoefUVV + envVal *= envSmthCoefUVV * envSmthCoefUVV + } + } else if ng.SinceUnvoiced < 2 { + for i := 0; i < l; i++ { + noiseUV[i] = 0.0 + } + } + ng.EnvLast = env[l-1] + } else { + for i := range ng.CorrSmth { + ng.CorrSmth[i] = 0.0 + } + for i := range ng.ShapeState { + ng.ShapeState[i] = 0.0 + } + for i := 0; i < l; i++ { + noiseV2[i] = 0.0 + } + + var nrgTgt float32 + if numPulses > 0 { + nrgRatio = smplNrg(excLpc[:l]) / (nrgres + 1e-20) + hardness := 10.0 + 20.0*normalizedBitrate + nrgTgt = nrgres * float32(math.Log(float64(float32(math.Exp(float64(hardness*(1.0-nrgRatio))))+1.0))) / hardness + smplGetEnv(excLpc, l, envSmthCoefUV, &ng.EnvSmth, env[:]) + } else { + nrgRatio = 0.0 + nrgTgt = nrgres + smplGetEnv0(l, envSmthCoefUV, &ng.EnvSmth, env[:]) + } + + scale := 1.0 / float32(l) + nrgTgt = nrgTgt*scale + 1e-30 + nrgEnv := smplNrg(env[:l]) * scale + f := float32(math.Sqrt(float64(nrgTgt))) + gg := float32(math.Sqrt(float64(nrgTgt / nrgEnv))) + ge := gg * env[0] + envLast := ng.EnvLast + if envLast < minF32(f, ge) { + if f < ge { + gg = 0.0 + } else { + f = 0.0 + } + } else if envLast > maxF32(f, ge) { + if f > ge { + gg = 0.0 + } else { + f = 0.0 + } + } else { + sumEnv := smplSum(env[:l]) * scale + a := nrgEnv + env[0]*env[0] - 2.0*sumEnv*env[0] + b := 2.0 * envLast * (sumEnv - env[0]) + cc := envLast*envLast - nrgTgt + tmp := b*b - 4.0*a*cc + if tmp < 1e-35 || a < 1e-25 { + f = 0.0 + gg = 0.0 + } else { + tmp = float32(math.Sqrt(float64(tmp))) + scale = 0.5 / a + gg = (-b + tmp) * scale + f = envLast - env[0]*gg + if f < 0.0 { + gg = (-b - tmp) * scale + f = envLast - env[0]*gg + } + } + } + + smplGenRandPulses(noiseUV[:], l, &ng.RandSeed) + if numPulses > 0 { + maxVal := fcbgainsUV[fcbgIdx] * 0.5 + for i := 0; i < l; i++ { + if excLpc[i] == 0.0 { + noiseUV[i] *= minF32(f+gg*env[i], maxVal) + } else { + noiseUV[i] = 0.0 + } + } + ng.EnvLast = minF32(f+gg*env[l-1], maxVal) + } else { + for i := 0; i < l; i++ { + noiseUV[i] *= f + gg*env[i] + } + ng.EnvLast = f + gg*env[l-1] + } + } + + if ng.PrevVoiced || voiced { + smplFiltMA2(noiseV2[:], l, coefMAV, &ng.OutStateV, noise) + } else { + for i := 0; i < l; i++ { + noise[i] = 0.0 + } + } + if ng.SinceUnvoiced < 2 || !voiced { + addNoiseUV(ng, noiseUV[:], l, lsf, nrgRatio, noise) + } else { + ng.OutStateUV = [2]float32{0.0, 0.0} + } + ng.PrevVoiced = voiced + if voiced { + ng.SinceUnvoiced++ + } else { + ng.SinceUnvoiced = 0 + } +} diff --git a/pkg/call/voip/media/mlow/perc.go b/pkg/call/voip/media/mlow/perc.go new file mode 100644 index 00000000..e8f87b4a --- /dev/null +++ b/pkg/call/voip/media/mlow/perc.go @@ -0,0 +1,528 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "math" + +// MLow perceptual-weighting front-end — faithful port of smpl_perc.rs +// (smpl_perc_wght.c FFT-based perceptual autocorrelation → perceptual LPC +// response, and smpl_bitrate_controller.c per-subframe pulse budget + importance). +// The C pffft (ordered real) is replaced by a self-contained mixed-radix complex +// FFT re-packed into pffft's exact ordered layout, so smth_filt indexes identical +// bins. PERCW_NFFT = 576 = 2^6 * 3^2 (not a power of two), hence mixed radix. +// +// Reuses smplPI (truncated literal), genSinWin/genCosWin, smplSigmoid from the +// package. Validated by perc-model smoke + bitrate-controller KATs and ultimately +// the encoder tone round-trip. + +const ( + percwNfft = 512 + 64 // 576 + percwFsKhz = 16.0 + percMaskSmth = 0.1158 + percMelFcHz = 320.0 + + winNextWbLen = 16 * 2 // 32 + winNextWbLongLen = 16 * 4 // 64 + win3ShortLen = winNextWbLen + win3LongLen = winNextWbLongLen + winPrevPercLen = 16 * 12 // 192 + percWin110msLen = 192 + percWin120msLen = 352 + + smplMaxLResp = 32 + 1 // 33 + smplMaxSfLen = 16 * 10 // 160 + smplPercRespLen = 16 * 2 // 32 + + // SmplPercReg is the perceptual-LPC autocorrelation regularization (smpl_perc_wght.h). + SmplPercReg float32 = 1e-3 + + smplE float32 = 2.7182818284590 + + // SmplFrameTypes + frameBackgroundNoise = 0 + frameUnvoiced = 1 + frameVoiced = 2 + + smplCelpIdxFec = 0 + smplCelpIdxMain = 1 + smplCelpMaxRates = smplCelpIdxMain + 1 // 2 + smplMaxPulsesPerSf = 40 + smplRateContScale = 26.0 +) + +// SmplPercEmphV / SmplPercEmphUV (smpl_tables.c): voiced / unvoiced pre-emphasis. +var ( + SmplPercEmphV = [2]float32{-0.72, -0.77} + SmplPercEmphUV = [2]float32{-0.55, -0.6} +) + +// [lowRate][BACKGROUND_NOISE/UNVOICED/VOICED] +var smplMaxPulsesPerFrame = [2][3]uint8{{80, 160, 160}, {16, 32, 32}} + +// [framelenidx][lowrate][8] +var smplRateControlModelComp5 = [4][2][8]float32{ + { + {5.166876656946171, -8.981699804753452, 0.07280811614105594, 0.1301196310618402, -0.01597680442864421, 1.7601470147884113, -3.8161195433141755, 0.3038629198331684}, + {-71.71229978402292, 14.197572549553076, -0.9863630205846172, 0.032124893286072924, -0.0003538411576874928, 1.803705259861388e-11, 10.0, 1.2454667523627154}, + }, + { + {32.5371190670542, -41.270234279452104, 10.490270829170875, -1.102121269442237, 0.03848319274046071, 3.405326741403831, -5.102658181889428, 0.2141935195026695}, + {-177.10486363500775, 43.952329593498376, -3.7049735533247454, 0.14239771116996938, -0.001919963993993193, 7.953695588409639e-6, 5.220317075476664, 0.6435364076926223}, + }, + { + {-79.2663194911617, 45.00981883522089, -10.063311543498518, 1.2311531056576501, -0.06023559069137118, 0.059204788212259364, 3.033961466462233, 1.0111383197827808}, + {-122.04861900525415, 31.62096398905459, -2.613237037423586, 0.10050433143234094, -0.0013233009240188039, 2.14859438836692e-7, 1.9077791307787761, 0.7059420500333776}, + }, + { + {-182.64255084224325, 122.90780796179816, -31.308790671748525, 3.7850563849431462, -0.1750480676903051, 0.05399618467364628, 3.009451055091342, 1.1243365512229038}, + {-132.4565456943888, 34.361297004632966, -2.7956546289118887, 0.10428149547078584, -0.001322667891395693, 2.678747426340249e-6, 6.9940208056381925, 0.7551244069345737}, + }, +} + +// [framelenidx][lowrate] +var smplRateControlThrsComp5 = [4][2]uint16{{7500, 10000}, {4500, 5750}, {4000, 5000}, {4000, 4750}} + +// --- leaf vector helpers (smpl_codec_util.c) ------------------------------- + +func percMulVec(input, win, out []float32, l int) { + for i := 0; i < l; i++ { + out[i] = win[i] * input[i] + } +} + +func percScaleVec(x, y []float32, l int, g float32) { + for i := 0; i < l; i++ { + y[i] = x[i] * g + } +} + +func percAddScaleVec(x0, x1, y []float32, l int, g float32) { + for i := 0; i < l; i++ { + y[i] = x0[i] + g*x1[i] + } +} + +func percAddScaleVecInplace(x, y []float32, l int, g float32) { + for i := 0; i < l; i++ { + y[i] += g * x[i] + } +} + +// percFiltMa2 is smpl_filt_ma2: 2nd-order MA (may be non-monic). state is state[0..2]. +func percFiltMa2(x []float32, n int, coef []float32, state *[2]float32, y []float32) { + if coef[0] == 1.0 { + percAddScaleVec(x[1:], x, y[1:], n-1, coef[1]) + } else { + percScaleVec(x, y, n, coef[0]) + percAddScaleVecInplace(x, y[1:], n-1, coef[1]) + } + percAddScaleVecInplace(x, y[2:], n-2, coef[2]) + y[0] = coef[0]*x[0] + coef[1]*state[0] + coef[2]*state[1] + y[1] += coef[2] * state[0] +} + +// percAc2rcDbl is smpl_ac2rc_dbl: autocorrelation → reflection coeffs (Levinson, f64). +func percAc2rcDbl(corr []float64, order int, reg float64, rc []float32) { + c0 := make([]float64, order+1) + c1 := make([]float64, order+1) + copy(c0, corr[:order+1]) + c0[0] *= 1.0 + reg + copy(c1, c0) + for i := 0; i < order; i++ { + rc[i] = 0.0 + } + for k := 0; k < order; k++ { + if c0[k+1] > c1[0] { + rc[k] = -1.0 + break + } + if c0[k+1] < -c1[0] { + rc[k] = 1.0 + break + } + if c1[0] == 0.0 { + break + } + rcTmp := -c0[k+1] / c1[0] + rc[k] = float32(rcTmp) + for n := 0; n < order-k; n++ { + ctmp1 := c0[n+k+1] + ctmp2 := c1[n] + c0[n+k+1] = ctmp1 + ctmp2*rcTmp + c1[n] = ctmp2 + ctmp1*rcTmp + } + } +} + +// percAc2rc is smpl_ac2rc: float wrapper promoting to double before Levinson. +func percAc2rc(corr []float32, order int, reg float32, rc []float32) { + corrDbl := make([]float64, order+1) + for i := 0; i < order+1; i++ { + corrDbl[i] = float64(corr[i]) + } + percAc2rcDbl(corrDbl, order, float64(reg), rc) +} + +// percRc2a is smpl_rc2a: reflection coeffs → LPC polynomial A[0..order]. +func percRc2a(rc []float32, order int, a []float32) { + for v := 1; v <= order; v++ { + a[v] = 0.0 + } + a[0] = 1.0 + for k := 0; k < order; k++ { + rcTmp := rc[k] + for n := 0; n < (k+1)/2; n++ { + tmp1 := a[n+1] + tmp2 := a[k-n] + a[n+1] = tmp1 + tmp2*rcTmp + a[k-n] = tmp2 + tmp1*rcTmp + } + a[k+1] = rcTmp + } +} + +// --- inverse real FFT (forward + cfft live in fft.go) ---------------------- + +// rfftBackwardOrdered: inverse real FFT from the ordered REAL layout, unnormalized. +func rfftBackwardOrdered(f []float32, time []float32) { + n := len(f) + spec := make([]cpx, n) + spec[0] = cpx{f[0], 0} + spec[n/2] = cpx{f[1], 0} + for i := 1; i < n/2; i++ { + re := f[2*i] + im := f[2*i+1] + spec[i] = cpx{re, im} + spec[n-i] = cpx{re, -im} + } + tout := make([]cpx, n) + cfft(spec, tout, 1.0) + for i := 0; i < n; i++ { + time[i] = tout[i].re + } +} + +// --- perceptual model (smpl_perc_wght.c) ----------------------------------- + +type percWindows struct { + percWin110ms []float32 + percWin120ms []float32 + win3Short []float32 + win3Long []float32 +} + +func newPercWindows() percWindows { + return percWindows{ + percWin110ms: genSinWin(percWin110msLen), + percWin120ms: genSinWin(percWin120msLen), + win3Short: genCosWin(win3ShortLen), + win3Long: genCosWin(win3LongLen), + } +} + +// smplWindowPerc is smpl_window for the perc case (use_lpc_win == FALSE). +func smplWindowPerc(win *percWindows, input, out []float32, length int, frameMs int32, useLongWin bool) { + win1len := percWin120msLen + win1 := win.percWin120ms + if frameMs == 10 { + win1len = percWin110msLen + win1 = win.percWin110ms + } + win3len := win3ShortLen + win3 := win.win3Short + if useLongWin { + win3len = win3LongLen + win3 = win.win3Long + } + + percMulVec(input, win1, out, win1len) + mid := length - win1len - win3LongLen + copy(out[win1len:win1len+mid], input[win1len:win1len+mid]) + percMulVec(input[length-win3LongLen:], win3, out[length-win3LongLen:], win3len) + if !useLongWin { + start := length - win3LongLen + win3ShortLen + for i := start; i < length; i++ { + out[i] = 0.0 + } + } +} + +// smthFilt is the bidirectional masking smooth across the power spectrum. +func smthFilt(f []float32, smthcoef []float32) { + half := percwNfft / 2 + f2smth := f[0] + for i := 1; i < half; i++ { + f2new := f[2*i] + f2smth = f2new + smthcoef[i]*(f2smth-f2new) + f[2*i] = f2smth + } + f[1] = f[1] + smthcoef[half]*(f2smth-f[1]) + f2smth = f[1] + for i := half - 1; i > 0; i-- { + f2new := f[2*i] + f2smth = f2new + smthcoef[i]*(f2smth-f2new) + f[2*i] = f2smth + } + f[0] = f[0] + smthcoef[0]*(f2smth-f[0]) +} + +// PercModelState carries the buf history (PERCW_NFFT) across SmplPercModel calls. +type PercModelState struct { + buf [percwNfft]float32 + smthcoef []float32 + windows percWindows +} + +// NewPercModelState builds the per-bin mel-width smoothing coefficients (smpl_create_perc_model_tables). +func NewPercModelState() *PercModelState { + fsStep := (percwFsKhz * 1000.0) / float32(percwNfft) + smthcoef := make([]float32, percwNfft/2+1) + for i := 0; i < percwNfft/2+1; i++ { + percWidthPerBin := percMaskSmth * (fsStep*float32(i) + percMelFcHz) / fsStep + smthcoef[i] = percWidthPerBin / (percWidthPerBin + 1.0) + } + return &PercModelState{smthcoef: smthcoef, windows: newPercWindows()} +} + +// SmplPercModel: windowed power spectrum → bidirectional masking smooth → inverse → +// 1/NFFT scale. Returns the first lenR autocorrelation lags. buf advances as the C. +func SmplPercModel(state *PercModelState, xsubfr []float32, xsubfrLen int, frameMs int32, isLastSubfr int32, lenR int) []float32 { + srcOff := xsubfrLen - (winNextWbLongLen - winNextWbLen) + keep := percwNfft - xsubfrLen + copy(state.buf[0:keep], state.buf[srcOff:srcOff+keep]) + copy(state.buf[keep:keep+xsubfrLen], xsubfr[:xsubfrLen]) + + winlen := winPrevPercLen + int(frameMs)*16 + win3LongLen + skipSamples := percwNfft - winlen + + bufWin := make([]float32, percwNfft) + smplWindowPerc(&state.windows, state.buf[skipSamples:], bufWin[skipSamples:], winlen, frameMs, isLastSubfr == 0) + + f := make([]float32, percwNfft) + rfftForwardOrdered(bufWin, f) + f[0] = f[0] * f[0] + f[1] = f[1] * f[1] + for i := 1; i < percwNfft/2; i++ { + f[2*i] = f[2*i]*f[2*i] + f[2*i+1]*f[2*i+1] + f[2*i+1] = 0.0 + } + smthFilt(f, state.smthcoef) + rfftBackwardOrdered(f, bufWin) + + r := make([]float32, lenR) + percScaleVec(bufWin, r, lenR, 1.0/float32(percwNfft)) + return r +} + +// SmplPercAc2a: ma2 (b={pe, 1+pe^2, pe}) on R[1..] then Levinson + rc2a → A[0..percRespLen]. +func SmplPercAc2a(r []float32, lenR int, percEmph float32, percRespLen int, reg float32) []float32 { + b := []float32{percEmph, 1.0 + percEmph*percEmph, percEmph} + state := [2]float32{r[0], r[1]} + rTmp := make([]float32, smplMaxLResp) + percFiltMa2(r[1:], percRespLen, b, &state, rTmp) + + rc := make([]float32, smplMaxLResp) + percAc2rc(rTmp, percRespLen-1, reg, rc) + + a := make([]float32, percRespLen) + percRc2a(rc, percRespLen-1, a) + return a +} + +// --- bitrate controller (smpl_bitrate_controller.c) ------------------------ + +func bitrate2pulses(rateKbps float32, coeff *[8]float32) float32 { + return coeff[0] + + coeff[1]*rateKbps + + coeff[2]*rateKbps*rateKbps + + coeff[3]*float32(math.Pow(float64(rateKbps), 3.0)) + + coeff[4]*float32(math.Pow(float64(rateKbps), 4.0)) + + coeff[5]*float32(math.Pow(float64(smplE), float64((rateKbps-coeff[6])*coeff[7]))) +} + +func bitrate2pulsesHrFec(rateKbps float32, coeff *[8]float32, onePulseRateBps float32) float32 { + const rateThresKbps float32 = 9.0 + if rateKbps >= rateThresKbps { + return bitrate2pulses(rateKbps, coeff) + } else if onePulseRateBps >= rateThresKbps*1000.0 { + return 1.0 + } + pulsesThres := bitrate2pulses(rateThresKbps, coeff) + sc := (rateThresKbps - rateKbps) / (rateThresKbps - onePulseRateBps/1000.0) + return pulsesThres - sc*(pulsesThres-1.0) +} + +// BitrateControllerInputs are the smpl_EncControlStruct fields the controller reads. +type BitrateControllerInputs struct { + InternalSampleRate int32 + PayloadSizeMs int32 + FecBitRate int32 + MainBitRate int32 + Complexity int32 + UseFecRateCompensation int32 + UseDtx int32 + SubFrameImportanceFactor float32 +} + +// BitrateController state carried across frames. +type BitrateController struct { + prevVoiced int32 + rateContWnrgSmth float32 + rateContBitrateScale [smplCelpMaxRates]float32 + bitrateDeltaSmth [smplCelpMaxRates]float32 + rateContBitrate [smplCelpMaxRates]float32 + adjustmentFactor [smplCelpMaxRates]float32 +} + +// NewBitrateController is bitrate_controller_init + zeroed state. +func NewBitrateController() *BitrateController { + bc := &BitrateController{} + for i := range bc.adjustmentFactor { + bc.adjustmentFactor[i] = 1.0 + } + return bc +} + +// control is bitrate_controller. Returns (max_pulses_per_subfr, subfr_importance). +func (bc *BitrateController) control( + enc *BitrateControllerInputs, + dtxSidFrame, codedAsActiveVoice int32, + spActProb, nonflatness, voicingStrength float32, + voiced int32, + wnrg, wnrgNext float32, + lowRate, framelen, subfrlen int32, +) ([smplCelpMaxRates]int16, [smplCelpMaxRates]float32) { + var bweBitrate int32 + if enc.InternalSampleRate > 16000 { + if lowRate != 0 { + bweBitrate += 450 + } else { + bweBitrate += 750 + } + if enc.PayloadSizeMs == 10 { + bweBitrate += 450 + } + } + + bc.rateContWnrgSmth += 0.6 * (wnrg - bc.rateContWnrgSmth) + + framelenIdx := 3 + switch enc.PayloadSizeMs { + case 10: + framelenIdx = 0 + case 20: + framelenIdx = 1 + case 60: + framelenIdx = 2 + } + + var maxPulsesPerSubfr [smplCelpMaxRates]int16 + var subfrImportance [smplCelpMaxRates]float32 + + startR := 0 + if (smplCelpIdxFec+boolToInt(enc.FecBitRate == 0)) != 0 || enc.FecBitRate == enc.MainBitRate { + startR = 1 + } + + lrIdx := 1 + if lowRate != 0 { + lrIdx = 0 + } + + for r := startR; r <= smplCelpIdxMain; r++ { + bitRate := float32(enc.MainBitRate) + if r == smplCelpIdxFec { + bitRate = float32(enc.FecBitRate) + } + if bitRate > 30000.0 { + bitRate = 30000.0 + } + rateKbps := (bitRate - float32(bweBitrate)) / 1000.0 + if lowRate == 0 { + switch enc.Complexity { + case 1, 2: + rateKbps *= 0.9900990 + case 3, 4: + rateKbps *= 1.0101010 + } + } + + var pulsesPer20msTargetMax float32 + rateControlThrs := float32(smplRateControlThrsComp5[framelenIdx][lrIdx]) + if (bitRate - float32(bweBitrate)) < rateControlThrs { + pulsesPer20msTargetMax = 1.0 + } else { + coeff := &smplRateControlModelComp5[framelenIdx][lrIdx] + if r == smplCelpIdxFec && lowRate == 0 && enc.UseFecRateCompensation != 0 { + pulsesPer20msTargetMax = maxF32(bitrate2pulsesHrFec(rateKbps, coeff, rateControlThrs), 1.0) + } else { + pulsesPer20msTargetMax = maxF32(bitrate2pulses(rateKbps, coeff), 1.0) + } + } + + relPulserate := pulsesPer20msTargetMax / 16.0 * (320.0 / float32(framelen)) + relPulserateLog := float32(math.Log(float64(relPulserate))) + if bc.rateContBitrate[r] != bitRate { + bitrateScale := float32(smplRateContScale) * relPulserate * (1.0 + 0.4*relPulserateLog*relPulserateLog) + bc.rateContBitrateScale[r] = bitrateScale + bc.rateContBitrate[r] = bitRate + } + + numsubfrs := framelen / subfrlen + mpps := 1 + int32(math.Round(float64(pulsesPer20msTargetMax*(1.0+0.5)/float32(numsubfrs)))) + if enc.UseDtx != 0 && dtxSidFrame != 0 { + mpps = 0 + } else { + mpps = int32(math.Round(float64(float32(mpps) * (0.5 + 0.5*float32(math.Sqrt(float64(spActProb+1e-12))))))) + frameType := frameBackgroundNoise + if codedAsActiveVoice != 0 { + if voiced == 1 { + frameType = frameVoiced + } else { + frameType = frameUnvoiced + } + } + maxPulses := int32(smplMaxPulsesPerFrame[lowRate][frameType]) * framelen / 320 + if m := maxPulses / numsubfrs; mpps > m { + mpps = m + } + } + maxPulsesPerSubfr[r] = int16(mpps) + + imp := (wnrg + 0.01*wnrgNext) / (bc.rateContWnrgSmth + 0.02*wnrgNext + 1e-12) + if voiced != 0 { + if bitRate <= 9000.0 { + imp = float32(math.Sqrt(float64(imp + 1e-12))) + } + } else { + imp *= 0.9 + 0.3*smplSigmoid(nonflatness-2.0) + imp *= 0.8 + } + if voiced != bc.prevVoiced { + imp *= 1.1 + } + imp *= 0.9 + 0.3*1.0/(1.0+25.0*voicingStrength*voicingStrength) + + impFactor := enc.SubFrameImportanceFactor + if impFactor <= 1.0 { + imp *= (1.0 - impFactor) + impFactor*float32(math.Sqrt(float64(spActProb+1e-12))) + } else if impFactor <= 2.0 { + impFactor -= 1.0 + imp *= (1.0 - impFactor) + impFactor*spActProb + } else { + impFactor -= 2.0 + imp *= (1.0 - impFactor) + impFactor*spActProb*spActProb + } + imp *= bc.adjustmentFactor[r] * bc.rateContBitrateScale[r] + subfrImportance[r] = imp + bc.prevVoiced = voiced + } + + return maxPulsesPerSubfr, subfrImportance +} + +func boolToInt(b bool) int32 { + if b { + return 1 + } + return 0 +} diff --git a/pkg/call/voip/media/mlow/pitch.go b/pkg/call/voip/media/mlow/pitch.go new file mode 100644 index 00000000..64a34cd5 --- /dev/null +++ b/pkg/call/voip/media/mlow/pitch.go @@ -0,0 +1,345 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "sync" + +// Pitch / LTP parameters. The decode side (DecodeSmplPitch) reads the LTP gains and +// pitch lags from the bitstream and is the KAT-verified path; the estimator side +// (SmplPitch) is the encoder analysis and is a known soft-divergence (see datasheet). + +const ( + // NumSubframes is the estimator's 8 pitch sub-blocks per 20 ms internal frame. + NumSubframes = 8 + // MaxLTPBufLen is the perceptually-weighted speech buffer length the estimator reads. + MaxLTPBufLen = 659 +) + +// ---- Decode side ---- + +// SmplPitchResult is the decoded LTP/pitch parameters for one internal frame. +type SmplPitchResult struct { + GainIdx [4]int32 + FiltIdx [4]int32 + Lag int32 + Contour int32 + SampleLagQ6 [8]int32 // per-segment reconstructed pitch lag in Q6 (1/64-sample) + NumSeg int32 + IntLagQ6 [4]int32 // per-subframe pitch lag in Q6 + BlockLags [8]int32 // per-40-sample-block lags (8 per 20 ms frame) + NumSubfr int32 +} + +// DecodeSmplPitch decodes the LTP gains and pitch lags. p3 = num subframes, +// p6 = config, subfrCounts = per-subframe pulse counts (from the pulse decode). +func DecodeSmplPitch(dec *RangeDecoder, mem *SmplMem, st *SmplLsfState, p2, p3, p6 int32, subfrCounts [4]int32) SmplPitchResult { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch.rs#L32-L198 + res := SmplPitchResult{FiltIdx: [4]int32{-1, -1, -1, -1}} + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_pitch.rs#L60-L88 (seed cc-table rewire: Group C LTP gains from CcTables; lag block below still mem) + cc := LoadCcTables() + + // --- LTP gains loop (Group C, served from CcTables; the lag block below still + // reads the Group-D heap window via mem). Both selects key on p6 (active WB path + // is p6==0 HR; the p6!=0 LR variant); the filter CDFs are shared across p6. + var gainAccum int32 + take := int(p3) + if take > 4 { + take = 4 + } + for sf := 0; sf < take; sf++ { + cnt := subfrCounts[sf] + var gi int32 + if p6 != 0 { + gi = dec.DecodeCDF(cc.AcbgainRowLr(st.PrevGainIdx)) + } else { + gi = dec.DecodeCDF(cc.AcbgainRow(st.PrevGainIdx)) + } + res.GainIdx[sf] = gi + st.PrevGainIdx = gi + + var w0, w2 int32 + if p6 != 0 { + w0, w2 = cc.AcbgainWeightsLr(gi) + } else { + w0, w2 = cc.AcbgainWeights(gi) + } + gainAccum += w0 + 2*w2 + + if cnt > 0 { + var fi int32 + if st.PrevFiltIdx == -1 { + fi = dec.DecodeCDF(cc.FcbgainV()) + } else { + fi = dec.DecodeCDF(cc.FcbgainVDelta(st.PrevFiltIdx)) + } + res.FiltIdx[sf] = fi + st.PrevFiltIdx = fi + } + } + avgGain := gainAccum / p3 // drives the fractional-lag segment select + + // --- Lag block --- + pcfg := mem.GClk + 0x5704 + numContours := int32(mem.U32(pcfg + 22240)) + lagCdf := mem.U32(pcfg + 22248) + contourMap := mem.U32(pcfg + 22244) + fracBase := mem.U32(pcfg + 22252) + deltaCdf := mem.U32(pcfg + 22268) + + // primary lag: + var lag int32 + if st.PrevLag < 0 { + cnt := numContours + 1 + if cnt < 0 { + cnt = 0 + } + lag = dec.DecodeCDF(mem.CDFAt(lagCdf, int(cnt))) + } else { + di := dec.DecodeCDF(mem.CDFAt(deltaCdf+uint32(st.PrevLag)*20, 10)) + lo := int32(mem.U8(0xe7ef0 + uint32(di)*2)) + hi := int32(mem.U8(0xe7ef0 + uint32(di)*2 + 1)) + rN := (hi - lo) + 2 + if rN < 2 { + res.Lag = -1 + return res // malformed delta interval + } + sym := dec.DecodeCDF(mem.CDFAt(lagCdf+uint32(lo)*2, int(rN))) + lag = sym + lo + } + + // contour-map search: find index where contour_map[i] == lag+1. + target := lag + 1 + contour := int32(-1) + for i := int32(0); i < 217; i++ { + if int32(mem.U8(contourMap+uint32(i))) == target { + contour = i + break + } + } + res.Lag = lag + res.Contour = contour + if contour < 0 || contour >= numContours { + return res // out-of-range; stop consuming pitch bits + } + + ctrBase := pcfg + uint32(contour)*0x44 + baseLag := mem.I32(ctrBase + 0x1d38) // contour base lag + + // (a) 64-symbol fine lag — read UNLESS prev_lag>=0 && -1 <= (base_lag-prev_lag) < 3. + curLag2 := baseLag + readFine := true + if st.PrevLag >= 0 { + delta := baseLag - st.PrevLag + if delta >= -1 && delta < 3 { + readFine = false + } + } + var subfrW int32 + if readFine { + sym := dec.Decode64FineSym() + curLag2 = (baseLag << 6) + sym + st.PrevFracLag = curLag2 + st.PrevLag = baseLag + segLen0 := mem.I32(ctrBase + 0x1d58) + for i := int32(0); i < segLen0; i++ { + if subfrW < 4 { + res.IntLagQ6[subfrW] = curLag2 + } + if subfrW < 8 { + res.BlockLags[subfrW] = curLag2 + } + subfrW++ + } + if subfrW < 4 { + res.IntLagQ6[subfrW] = curLag2 // trailing write, subfr_w not incremented + } + if subfrW < 8 { + res.BlockLags[subfrW] = curLag2 + } + } + + // (b) fractional per-segment loop: + cnt2 := mem.I32(ctrBase + 0x1d78) + var segSel int32 + if avgGain >= 10007 { + if avgGain < 14085 { + segSel = 1 + } else { + segSel = 2 + } + } + fracSegBase := fracBase + uint32(segSel)*0x280 + l3 := st.PrevFracLag + l2 := curLag2 + startSeg := int32(0) + if readFine { + startSeg = 1 + } + res.NumSeg = cnt2 + for seg := startSeg; seg < cnt2; seg++ { + segLag := mem.I32(ctrBase + 0x1d38 + uint32(seg)*4) + nl2 := ((l2 << 6) - l3) + ((segLag - l2) << 6) + off := fracSegBase + uint32(nl2*2) + 0xfe + sym := dec.DecodeCDF(mem.CDFAt(off, 65)) + l3 = sym + st.PrevFracLag + nl2 + if seg < 8 { + res.SampleLagQ6[seg] = l3 + } + segLen := mem.I32(ctrBase + 0x1d58 + uint32(seg)*4) + for i := int32(0); i < segLen; i++ { + if subfrW < 4 { + res.IntLagQ6[subfrW] = l3 + } + if subfrW < 8 { + res.BlockLags[subfrW] = l3 + } + subfrW++ + } + l2 = segLag + st.PrevFracLag = l3 + st.PrevLag = segLag + } + res.NumSubfr = subfrW + return res +} + +// ---- Estimator side ---- + +// PitchEstState is the per-stream estimator state (cross-frame lag-block predictor). +type PitchEstState struct { + PrevLag float32 + PrevPitchCorr float32 + PrevLagblk int32 + PrevLagidx int32 +} + +// ResetCond clears the cross-frame lag-block predictor (smpl_pitch_reset_cond): +// called after the last frame of a packet and after any unvoiced frame. +func (s *PitchEstState) ResetCond() { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch_enc.rs#L337-L341 + s.PrevLagblk = -1 + s.PrevLagidx = -1 +} + +// PitchResult is the pitch estimator result for one internal frame. +type PitchResult struct { + Pitchcorr float32 + Lags [NumSubframes]float32 + Laginds [NumSubframes]int32 + AvgLag float32 + HarmStrength float32 + BlocksegIdx int +} + +// pitchBlockSeg / pitchBlockTrack mirror the reference PitchTables sub-records. +type pitchBlockSeg struct { + Nblocks int + Blocks []int + Seglens []int +} + +type pitchBlockTrack struct { + Track [NumSubframes]int + Meanblock float32 + Trackdeltas float32 +} + +// PitchTables holds the loaded constant tables (the smpl_pitch_tables dump). +type PitchTables struct { + Blocksegs []pitchBlockSeg + Blocktracks []pitchBlockTrack + Blocksegs2idx []int + BlocksegIdxCmf []uint32 + DeltaLagCmfs [][]uint32 + BlocksegsIx [][2]int + FirstblockRange [][2]int + BlockTransitionCmf [][]uint32 +} + +var ( + pitchTablesOnce sync.Once + pitchTables *PitchTables +) + +// LoadPitchTables expands the embedded pitch seed ROM (pitch_seed.bin) into the full +// pitch tables once and returns the shared set. The expansion (range-decode of the +// blocksegs bitstream + integer DCMF→CDF) is in pitch_seed.go (buildPitchTablesFromSeed) +// and is bit-identical to the old smpl_pitch_tables.json blob. +func LoadPitchTables() *PitchTables { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L111-L158 + pitchTablesOnce.Do(func() { pitchTables = buildPitchTablesFromSeed() }) + return pitchTables +} + +// pitch lag-contour wire constants (smpl_pitch_enc.rs). +const ( + pitchBlocksize = 64 // PITCHBLOCK_MS(2) * FS_KHZ(16) * 2 + pitchNumBlocks = 9 // (MAXPITCH_MS - MINPITCH_MS)/PITCHBLOCK_MS + pitchNumSubframes = NumSubframes +) + +// encodeLagsWire is the faithful port of C smpl_encode_lags (pEcCtx != NULL): write +// the blockseg selector + the per-40-block lag indices (laginds) to the range stream. +// This IS the voiced lag wire encode, the inverse of DecodeSmplPitch's contour +// reconstruction. prevLagblk/prevLagidx are the lag predictor (-1 at packet start / +// after a no-match); mode (0/1/2 by mean ACB gain) selects the delta-lag CMF. +func encodeLagsWire(tab *PitchTables, enc *RangeEncoder, blocksegsIx int, laginds *[NumSubframes]int32, prevLagblk, prevLagidx int32, mode int) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch_enc.rs#L726-L799 + ixJulia := int32(tab.Blocksegs2idx[blocksegsIx]) + blocksize := int32(pitchBlocksize) + pblockseg := &tab.Blocksegs[blocksegsIx] + + if prevLagblk < 0 { + cmf := tab.BlocksegIdxCmf + enc.Encode(cmf[ixJulia-1], cmf[ixJulia], cmf[len(tab.Blocksegs)]) + } else { + cmf := tab.BlockTransitionCmf[prevLagblk] + b0 := pblockseg.Blocks[0] + enc.Encode(cmf[b0], cmf[b0+1], cmf[pitchNumBlocks]) + startIx := int32(tab.FirstblockRange[b0][0]) + cmfLen := int32(tab.FirstblockRange[b0][1] - tab.FirstblockRange[b0][0] + 1) + cmf2 := tab.BlocksegIdxCmf[startIx:] + lo := ixJulia - startIx - 1 + hi := ixJulia - startIx + enc.Encode(cmf2[lo]-cmf2[0], cmf2[hi]-cmf2[0], cmf2[cmfLen]-cmf2[0]) + } + + blk := int32(pblockseg.Blocks[0]) + deltaBlk := blk - prevLagblk + startSeg := 0 + lagindsIx := 0 + if !(prevLagblk > -1 && deltaBlk >= -1 && deltaBlk <= 2) { + idxMod := uint32(laginds[lagindsIx] - blk*blocksize) + enc.Encode(idxMod, idxMod+1, uint32(blocksize)) + prevLagblk = blk + prevLagidx = laginds[lagindsIx] + lagindsIx += pblockseg.Seglens[0] + startSeg = 1 + } + deltaLagCmf := tab.DeltaLagCmfs[mode] + for k := startSeg; k < pblockseg.Nblocks; k++ { + blk = int32(pblockseg.Blocks[k]) + idx := laginds[lagindsIx] + lagindsIx += pblockseg.Seglens[k] + deltaBlk = blk - prevLagblk + deltaIdx := idx - prevLagidx + prevLagidxMod := prevLagidx - prevLagblk*blocksize + deltaRangeStart := -prevLagidxMod + deltaBlk*blocksize + cmfBase := int(deltaRangeStart + 2*blocksize - 1) + ix := int(deltaIdx - deltaRangeStart) + p0 := deltaLagCmf[cmfBase] + enc.Encode(deltaLagCmf[cmfBase+ix]-p0, deltaLagCmf[cmfBase+ix+1]-p0, deltaLagCmf[cmfBase+int(blocksize)]-p0) + prevLagblk = blk + prevLagidx = idx + } +} + +// smplLagsPredictorAfter is the lag predictor after the voiced lag encode: +// prevLagblk = blocks[nblocks-1], prevLagidx = laginds[NumSubframes-1]. +func smplLagsPredictorAfter(tab *PitchTables, blocksegsIx int, laginds *[NumSubframes]int32) (int32, int32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch_enc.rs#L803-L811 + pblockseg := &tab.Blocksegs[blocksegsIx] + lastBlk := int32(pblockseg.Blocks[pblockseg.Nblocks-1]) + return lastBlk, laginds[NumSubframes-1] +} + +// SmplPitch (the full multi-stage estimator) is implemented in pitch_enc.go. diff --git a/pkg/call/voip/media/mlow/pitch_enc.go b/pkg/call/voip/media/mlow/pitch_enc.go new file mode 100644 index 00000000..85f2c4ef --- /dev/null +++ b/pkg/call/voip/media/mlow/pitch_enc.go @@ -0,0 +1,680 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "math" + +// MLow pitch estimator — faithful port of smpl_pitch (smpl_pitch_enc.rs / the C +// smpl_pitch_util.c). HP-filters + 2x-downsamples the perceptually-weighted +// ltp_buf, runs an open-loop block-track survivor search at the coarse (16 kHz +// upsampled from 8 kHz) resolution, refines per-block at full resolution around +// the survivors, and folds in the rate / prev-lag / spectral-harmonicity biases. +// Only the 20 ms / 8-subframe config (the active MLow 1:1 path) is supported. +// +// Validated by pitchio_ground_truth.json: exact laginds/blockseg_idx + pitchcorr/ +// avg_lag within 1e-3 + harm within the cache-aliasing tol. + +const ( + peFsKhz = 16 + peStage1FsKhz = 8 + peCoarseFsKhz = 16 + peTotInterpDelay = 6 + peMinpitchMs = 2 + peMaxpitchMs = 20 + peMinpitchLen = peMinpitchMs * peFsKhz // 32 + peMaxpitchLen = peMaxpitchMs * peFsKhz // 320 + peMinpitchStage1 = peMinpitchMs*peStage1FsKhz - peTotInterpDelay // 10 + peMaxpitchStage1 = peMaxpitchMs*peStage1FsKhz + peTotInterpDelay // 166 + + pePitchDeltawght float32 = 0.1439 + pePitchShortwght1 float32 = 0.04 + peSpecHarmBias float32 = 2.5 + pePrevwght float32 = 0.7981 + pePrevwghtSpan float32 = 0.15 + peRatewghtHr float32 = 0.022 + + peLagSubfrlen = 40 + peLagSubfrlenStage1 = peStage1FsKhz * peLagSubfrlen / peFsKhz // 20 + pePitchblockMs = 2 + pePitchLookaheadLen = 7 + + peDownsampDelay = 7 + peInterpolDelayC = 4 + pePitchblock = pePitchblockMs * peFsKhz // 32 + peNumLagsStage1 = peMaxpitchStage1 - peMinpitchStage1 + 1 // 157 + peNumlagsCoarse = peCoarseFsKhz * (peMaxpitchMs - peMinpitchMs) // 288 + peNumlagsFs = peFsKhz * (peMaxpitchMs - peMinpitchMs) // 288 + peNumstates1 = 24 + peLowComplexity = false + peLowRate = false +) + +// --- filters / DSP helpers -------------------------------------------------- + +// pePitchHpFilter is smpl_filt_arma1 with pitch_hp_b={1,-1}, pitch_hp_a={1,-0.96}, +// zero state: MA1 then AR1 in the C's 5-sample unrolled form. +func pePitchHpFilter(x []float32, out []float32) { + n := len(x) + var stateMa float32 + for i := 0; i < n; i++ { + out[i] = x[i] - stateMa + stateMa = x[i] + } + const ar1 float32 = 0.96 + ar12 := ar1 * ar1 + ar13 := ar1 * ar12 + ar14 := ar1 * ar13 + ar15 := ar1 * ar14 + var ytmp float32 + idx := 0 + for idx+4 < n { + x0, x1, x2, x3, x4 := out[idx], out[idx+1], out[idx+2], out[idx+3], out[idx+4] + out[idx+4] = x4 + ar1*x3 + ar12*x2 + ar13*x1 + ar14*x0 + ar15*ytmp + out[idx] = x0 + ar1*ytmp + out[idx+1] = x1 + ar1*x0 + ar12*ytmp + out[idx+2] = x2 + ar1*x1 + ar12*x0 + ar13*ytmp + out[idx+3] = x3 + ar1*x2 + ar12*x1 + ar13*x0 + ar14*ytmp + ytmp = out[idx+4] + idx += 5 + } + for idx < n { + ytmp = out[idx] + ytmp*ar1 + out[idx] = ytmp + idx++ + } +} + +var peDownsampFilt = [2*peDownsampDelay + 1]float32{ + -0.045472838, 0.0, 0.06366198, 0.0, -0.10610329, 0.0, 0.31830987, + 0.5, 0.31830987, 0.0, -0.10610329, 0.0, 0.06366198, 0.0, -0.045472838, +} + +// pePitchDownsample is smpl_pitch_downsample: 2x decimating FIR. +func pePitchDownsample(ptrIn []float32, l int, ptrOut []float32) int { + d := peDownsampDelay + n := (l - 2*d) / 2 + for j := 0; j < n; j++ { + tmp := ptrIn[2*j+d] * peDownsampFilt[d] + for i := 0; i < d; i += 2 { + tmp += (ptrIn[2*j+i] + ptrIn[2*j+2*d-i]) * peDownsampFilt[i] + } + ptrOut[j] = tmp + } + return n +} + +var peInterpolFiltC = [2 * peInterpolDelayC]float32{ + -0.0024414062, 0.023925781, -0.119628906, 0.59814453, + 0.59814453, -0.119628906, 0.023925783, -0.0024414062, +} + +// peUpsampECore: writes 2*len samples backwards; even taps copy, odd taps average. +func peUpsampECore(buf []float32, xEnd, yEnd, length int) { + xi := xEnd + yi := yEnd + for k := 0; k < length; k++ { + v := (buf[xi] + buf[xi+1]) * 0.5 + buf[yi] = v + yi-- + buf[yi] = buf[xi] + yi-- + xi-- + } +} + +// peUpsampCCore: like upsamp_E but the interpolated sample uses the 8-tap filter. +func peUpsampCCore(buf []float32, xEnd, yEnd, length int) { + xi := xEnd + yi := yEnd + for k := 0; k < length; k++ { + var tmp float32 + for j := 0; j < peInterpolDelayC; j++ { + a := buf[xi+j-(peInterpolDelayC-1)] + b := buf[xi+peInterpolDelayC-j] + tmp += (a + b) * peInterpolFiltC[j] + } + buf[yi] = tmp + yi-- + buf[yi] = buf[xi] + yi-- + xi-- + } +} + +func peNrg(x []float32) float32 { + var s float32 + for _, v := range x { + s += v * v + } + return s +} + +func peMaximum(x []float32) float32 { + m := x[0] + for _, v := range x[1:] { + if v > m { + m = v + } + } + return m +} + +// peGetMaxi is smpl_get_maxi: argmax, ties → first index (strict >). +func peGetMaxi(x []float32) int { + bi := 0 + best := x[0] + for n := 1; n < len(x); n++ { + if x[n] > best { + best = x[n] + bi = n + } + } + return bi +} + +// peGetMaxiK is smpl_get_maxi_K: K highest indices in selection order (strict >, lowest-index-wins). +func peGetMaxiK(x []float32, k int) []int { + taken := make([]bool, len(x)) + out := make([]int, 0, k) + for c := 0; c < k; c++ { + bi := -1 + var best float32 + for n := 0; n < len(x); n++ { + if !taken[n] && (bi < 0 || x[n] > best) { + best = x[n] + bi = n + } + } + if bi < 0 { + break + } + taken[bi] = true + out = append(out, bi) + } + return out +} + +func peDotProd(a, b []float32, n int) float32 { + var r float32 + for i := 0; i < n; i++ { + r += a[i] * b[i] + } + return r +} + +func peDotProd40(a, b []float32) float32 { + var r float32 + for i := 0; i < 40; i++ { + r += a[i] * b[i] + } + return r +} + +// peCalcE1Inner is smpl_calc_E1: running energy of lag_subfrlen-length windows. +func peCalcE1Inner(e1, ltpbuf []float32, t int, minpitch, maxpitch, lagSubfrlen int) { + numlags := maxpitch - minpitch + 1 + reg0 := t - minpitch + e1[0] = maxF32(peNrg(ltpbuf[reg0:reg0+lagSubfrlen]), 1e-9) + for i := 1; i < numlags; i++ { + rm := ltpbuf[reg0-i] + rs := ltpbuf[reg0+lagSubfrlen-i] + e1[i] = maxF32(e1[i-1]+rm*rm-rs*rs, 1e-9) + } +} + +// peCalcE1 is smpl_pitch_calc_E1: per-subframe E1 via one extended E1_ then offsets. +func peCalcE1(e1, ltpbuf []float32, ltpbufLen, numsubfrs, minpitch, maxpitch, lagSubfrlen int) { + numlags := maxpitch - minpitch + 1 + maxpitch_ := maxpitch + (numsubfrs-1)*lagSubfrlen + numlags_ := maxpitch_ - minpitch + 1 + t := ltpbufLen - lagSubfrlen + e1Ext := make([]float32, numlags_) + peCalcE1Inner(e1Ext, ltpbuf, t, minpitch, maxpitch_, lagSubfrlen) + offset := numlags_ - numlags + for sf := 0; sf < numsubfrs; sf++ { + for i := 0; i < numlags; i++ { + e1[sf*numlags+i] = e1Ext[offset+i] + } + offset -= lagSubfrlen + } +} + +// peCalcCE2 is smpl_pitch_calc_C_E2: stage-1 cross-correlation C + target energy E2. +func peCalcCE2(c, e2, ltpbuf []float32, ltpbufLen, numsubfrs int) { + t := ltpbufLen - peLagSubfrlenStage1*numsubfrs + for sf := 0; sf < numsubfrs; sf++ { + tgt := ltpbuf[t : t+20] + reg0 := t - peMinpitchStage1 + for i := 0; i < peNumLagsStage1; i++ { + r := ltpbuf[reg0-i : reg0-i+20] + c[sf*peNumLagsStage1+i] = peDotProd(tgt, r, 20) + } + t += peLagSubfrlenStage1 + e2[sf] = maxF32(peDotProd(tgt, tgt, 20), 1e-9) + } +} + +// peUpsampEFast: in-place 2x upsample of a per-subframe E array, high subframe first. +func peUpsampEFast(buf []float32, numsubfrs int, minpitch *int, numlags *int) { + nin := *numlags + nout := (nin - 1) * 2 + for sf := numsubfrs - 1; sf >= 0; sf-- { + xEnd := sf*nin + nin - 2 + yEnd := sf*nout + nout - 1 + peUpsampECore(buf, xEnd, yEnd, nin-1) + } + *numlags = nout + *minpitch *= 2 +} + +// peUpsampCFast: in-place 2x upsample of a per-subframe C array via the interp filter. +func peUpsampCFast(buf []float32, numsubfrs int, minpitch *int, numlags *int) { + nin := *numlags + nout := (nin - peInterpolDelayC) * 2 + for sf := numsubfrs - 1; sf >= 0; sf-- { + xEnd := sf*nin + nin - 1 - peInterpolDelayC + yEnd := sf*nout + nout - 1 + peUpsampCCore(buf, xEnd, yEnd, nin-(peInterpolDelayC*2-1)) + } + *numlags = nout + *minpitch *= 2 +} + +func peSumdeltas(laginds []int32, numsubfrs int) int32 { + var ret int32 + for i := 1; i < numsubfrs; i++ { + d := laginds[i] - laginds[i-1] + if d < 0 { + d = -d + } + ret += d + } + return ret +} + +// peEcEncodeBits is ec_encode_wrap with pEcCtx==NULL: -log2((fh-fl)/ft). +func peEcEncodeBits(fl, fh, ft uint32) float32 { + p := (float32(fh) - float32(fl)) / float32(ft) + if p <= 0.0 { + return 0.0 + } + return -float32(math.Log2(float64(p))) +} + +// peEncodeLagsBits is smpl_encode_lags(.., pEcCtx=NULL): the bit cost used as a survivor bias. +func peEncodeLagsBits(tab *PitchTables, blocksegsIx int, laginds *[NumSubframes]int32, prevLagblk, prevLagidx int32, mode int) float32 { + var nBits float32 + ixJulia := int32(tab.Blocksegs2idx[blocksegsIx]) + blocksize := int32(pePitchblockMs * peFsKhz * 2) // 64 + pblockseg := &tab.Blocksegs[blocksegsIx] + + if prevLagblk < 0 { + cmf := tab.BlocksegIdxCmf + nBits += peEcEncodeBits(cmf[ixJulia-1], cmf[ixJulia], cmf[len(tab.Blocksegs)]) + } else { + cmf := tab.BlockTransitionCmf[prevLagblk] + b0 := pblockseg.Blocks[0] + nBits += peEcEncodeBits(cmf[b0], cmf[b0+1], cmf[pitchNumBlocks]) + startIx := int32(tab.FirstblockRange[b0][0]) + cmfLen := int32(tab.FirstblockRange[b0][1] - tab.FirstblockRange[b0][0] + 1) + cmf2 := tab.BlocksegIdxCmf[startIx:] + lo := ixJulia - startIx - 1 + hi := ixJulia - startIx + nBits += peEcEncodeBits(cmf2[lo]-cmf2[0], cmf2[hi]-cmf2[0], cmf2[cmfLen]-cmf2[0]) + } + + blk := int32(pblockseg.Blocks[0]) + deltaBlk := blk - prevLagblk + startSeg := 0 + lagindsIx := 0 + if !(prevLagblk > -1 && deltaBlk >= -1 && deltaBlk <= 2) { + nBits += 6.0 // uniform first-lag cost + prevLagblk = blk + prevLagidx = laginds[lagindsIx] + lagindsIx += pblockseg.Seglens[0] + startSeg = 1 + } + deltaLagCmf := tab.DeltaLagCmfs[mode] + for k := startSeg; k < pblockseg.Nblocks; k++ { + blk = int32(pblockseg.Blocks[k]) + idx := laginds[lagindsIx] + lagindsIx += pblockseg.Seglens[k] + deltaBlk = blk - prevLagblk + deltaIdx := idx - prevLagidx + prevLagidxMod := prevLagidx - prevLagblk*blocksize + deltaRangeStart := -prevLagidxMod + deltaBlk*blocksize + cmfBase := int(deltaRangeStart + 2*blocksize - 1) + ix := int(deltaIdx - deltaRangeStart) + p0 := deltaLagCmf[cmfBase] + nBits += peEcEncodeBits(deltaLagCmf[cmfBase+ix]-p0, deltaLagCmf[cmfBase+ix+1]-p0, deltaLagCmf[cmfBase+int(blocksize)]-p0) + prevLagblk = blk + prevLagidx = idx + } + return nBits +} + +// peSpectralHarmCached is spectral_harmonicity with a per-survivor cache keyed by harmonic bin. +func peSpectralHarmCached(avgLag float32, f2w *[SmplFLen]float32, cache []float32, reset bool) float32 { + const harmUndef float32 = -10000.0 + if reset { + for i := range cache { + cache[i] = harmUndef + } + } + invF2StepHz := 2.0 * float32(SmplFLen-1) / 16000.0 + harmHz := 16000.0 / avgLag + harmIx := int(math.Round(float64(harmHz * 2.0 * invF2StepHz))) + if harmIx < 0 || harmIx >= len(cache) { + return HarmStrengthAt(avgLag, f2w) + } + if cache[harmIx] > harmUndef { + return cache[harmIx] + } + hs := HarmStrengthAt(avgLag, f2w) + cache[harmIx] = hs + return hs +} + +func peGetPrevLagBias(st *PitchEstState, lag float32) float32 { + lagDiff := float32(math.Abs(float64(lag - st.PrevLag))) + diffThres := pePrevwghtSpan * st.PrevLag + if lagDiff < diffThres { + return st.PrevPitchCorr * (1.0 - lagDiff/diffThres) * pePrevwght + } + return 0.0 +} + +// SmplPitch is the full pitch estimator. ltpBuf is the perceptually-weighted speech +// of length MaxLTPBufLen (last PITCH_LOOKAHEAD_LEN samples are lookahead); f2 is the +// LPC power spectrum; codedAsActiveVoice gates the search. Mutates the predictor in st. +func SmplPitch(st *PitchEstState, ltpBuf []float32, f2 *[SmplFLen]float32, codedAsActiveVoice bool) PitchResult { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch_enc.rs#L848-L1215 + tab := LoadPitchTables() + numsubfrs := NumSubframes + l := MaxLTPBufLen + look := pePitchLookaheadLen + + if !codedAsActiveVoice { + minLag := float32(peMinpitchMs * peFsKhz) + st.PrevLag = 0.0 + st.PrevPitchCorr = 0.0 + st.PrevLagblk = -1 + st.PrevLagidx = -1 + res := PitchResult{Pitchcorr: 0.0, AvgLag: minLag, HarmStrength: 0.0, BlocksegIdx: 0} + for i := 0; i < NumSubframes; i++ { + res.Lags[i] = minLag + } + return res + } + + offset := peDownsampDelay + stage1 := make([]float32, l+offset+64) + pePitchHpFilter(ltpBuf, stage1[offset:offset+l]) + hpLen := l - look + ltpBufHp := make([]float32, hpLen) + copy(ltpBufHp, stage1[offset:offset+hpLen]) + + stage1Ds := make([]float32, (l+offset)/2+8) + stage1Len := pePitchDownsample(stage1, l+offset, stage1Ds) + + numlags0 := peNumLagsStage1 + e1 := make([]float32, numlags0*numsubfrs+16) + peCalcE1(e1, stage1Ds, stage1Len, numsubfrs, peMinpitchStage1, peMaxpitchStage1, peLagSubfrlenStage1) + e2 := make([]float32, numsubfrs) + cap := (2*peFsKhz/peStage1FsKhz)*peNumLagsStage1*numsubfrs + 64 + c := make([]float32, cap) + e := make([]float32, cap) + cStage1 := make([]float32, numlags0*numsubfrs) + peCalcCE2(cStage1, e2, stage1Ds, stage1Len, numsubfrs) + copy(c[:numlags0*numsubfrs], cStage1) + + numlags := numlags0 + for sf := 0; sf < numsubfrs; sf++ { + sqrtE1 := make([]float32, numlags) + for i := 0; i < numlags; i++ { + sqrtE1[i] = float32(math.Sqrt(float64(e1[sf*numlags+i] + 1e-30))) + } + sqrtE2 := float32(math.Sqrt(float64(e2[sf] + 1e-30))) + for i := 0; i < numlags; i++ { + tmp := 0.5 * (sqrtE1[i] + sqrtE2) + e[sf*numlags+i] = tmp * tmp + } + } + + minpitchC := peMinpitchStage1 + numlagsC := numlags + minpitchE := peMinpitchStage1 + numlagsE := numlags + if peLowComplexity { + peUpsampEFast(c, numsubfrs, &minpitchC, &numlagsC) + } else { + peUpsampCFast(c, numsubfrs, &minpitchC, &numlagsC) + } + peUpsampEFast(e, numsubfrs, &minpitchE, &numlagsE) + + minpitchCoarse := peCoarseFsKhz * peMinpitchMs + numlagsCoarse := peNumlagsCoarse + offsetC0 := minpitchCoarse - minpitchC + offsetE0 := minpitchCoarse - minpitchE + + h := make([]float32, numlagsCoarse*numsubfrs*2+64) + for sf := 0; sf < numsubfrs; sf++ { + for i := 0; i < numlagsCoarse; i++ { + cv := c[sf*numlagsC+offsetC0+i] + ev := e[sf*numlagsE+offsetE0+i] + h[sf*numlagsCoarse+i] = cv / ev + } + } + + pitchblockCoarse := pePitchblockMs * peCoarseFsKhz // 32 + var hblk [NumSubframes][pitchNumBlocks]float32 + for sf := 0; sf < numsubfrs; sf++ { + for block := 0; block < pitchNumBlocks; block++ { + base := sf*numlagsCoarse + block*pitchblockCoarse + hblk[sf][block] = peMaximum(h[base : base+pitchblockCoarse]) + } + } + + blocksizeFs := pePitchblock * 2 // 64 + const reductionFactor float32 = 0.7 + pitchDeltawght := pePitchDeltawght / float32(blocksizeFs) + var sfWght [NumSubframes]float32 + { + var sumE2 float32 + for sf := 0; sf < numsubfrs; sf++ { + sumE2 += e2[sf] + } + for sf := 0; sf < numsubfrs; sf++ { + sfWght[sf] = e2[sf] / sumE2 + } + } + numBlocktracks := len(tab.Blocktracks) + utils := make([]float32, numBlocktracks) + for i := 0; i < numBlocktracks; i++ { + bt := &tab.Blocktracks[i] + var corr float32 + for sf := 0; sf < numsubfrs; sf++ { + corr += hblk[sf][bt.Track[sf]] * sfWght[sf] + } + shortlagbias1 := (float32(peMaxpitchLen)/((bt.Meanblock+1.5)*float32(pePitchblock)) - 1.0) * pePitchShortwght1 + utils[i] = 1.0/(1.1-corr) - reductionFactor*float32(pePitchblock)*pitchDeltawght*bt.Trackdeltas + shortlagbias1 + } + trackIdx := peGetMaxiK(utils, peNumstates1) + + e1Fs := make([]float32, numlagsE*numsubfrs+16) + peCalcE1(e1Fs, ltpBufHp, l-look, numsubfrs, minpitchE, minpitchE+numlagsE-1, peLagSubfrlen) + + var uniqueblocks [NumSubframes]uint16 + for _, ti := range trackIdx { + track := &tab.Blocktracks[ti].Track + for sf := 0; sf < numsubfrs; sf++ { + uniqueblocks[sf] |= 1 << uint(track[sf]) + } + } + + var hThres float32 + if !peLowComplexity { + hThres = 0.25 + } + offsetC := peMinpitchMs*peFsKhz - minpitchC + offsetE := peMinpitchMs*peFsKhz - minpitchE + for sf := 0; sf < numsubfrs; sf++ { + var mask uint16 = 1 + cPtr := offsetC + sf*numlagsC + ePtr := offsetE + sf*numlagsE + e1Ptr := offsetE + sf*numlagsE + hPtr := sf * peNumlagsFs + ltpOff := (l - look) + (sf-numsubfrs)*peLagSubfrlen + e2sf := maxF32(peDotProd40(ltpBufHp[ltpOff:], ltpBufHp[ltpOff:]), 1e-9) + e2[sf] = e2sf + sqrtE2 := float32(math.Sqrt(float64(e2sf + 1e-30))) + for block := 0; block < pitchNumBlocks; block++ { + if uniqueblocks[sf]&mask != 0 { + var sqrtE1 [pePitchblock + 1]float32 + for i := 0; i < pePitchblock+1; i++ { + sqrtE1[i] = float32(math.Sqrt(float64(e1Fs[e1Ptr+block*pePitchblock+i] + 1e-30))) + } + for i := 0; i < pePitchblock+1; i++ { + tmp := 0.5 * (sqrtE1[i] + sqrtE2) + e[ePtr+block*pePitchblock+i] = 0.5 * tmp * tmp + } + for i := 0; i < pePitchblock; i++ { + if h[hPtr+block*pePitchblock+i] > hThres { + lag := peMinpitchLen + block*pePitchblock + i + a := ltpBufHp[ltpOff:] + b := ltpBufHp[ltpOff-lag:] + c[cPtr+block*pePitchblock+i] = 0.5 * peDotProd40(a, b) + } + } + } + mask <<= 1 + } + } + + strideC := pitchNumBlocks*2*pePitchblock + offsetC + strideE := pitchNumBlocks*2*pePitchblock + offsetE + for sf := numsubfrs - 1; sf >= 0; sf-- { + cPtr := offsetC + sf*numlagsC + cPtrFrac := offsetC + sf*strideC + ePtr := offsetE + sf*numlagsE + ePtrFrac := offsetE + sf*strideE + hPtr := sf * 2 * pePitchblock * pitchNumBlocks + var mask uint16 = 1 << uint(pitchNumBlocks-1) + for block := pitchNumBlocks - 1; block >= 0; block-- { + if uniqueblocks[sf]&mask != 0 { + ein := ePtr + block*pePitchblock + eout := ePtrFrac + block*2*pePitchblock + peUpsampECore(e, ein+pePitchblock-1, eout+2*pePitchblock-1, pePitchblock) + cin := cPtr + block*pePitchblock + cout := cPtrFrac + block*2*pePitchblock + if peLowComplexity { + peUpsampECore(c, cin+pePitchblock-1, cout+2*pePitchblock-1, pePitchblock) + } else { + peUpsampCCore(c, cin+pePitchblock-1, cout+2*pePitchblock-1, pePitchblock) + } + for i := 0; i < 2*pePitchblock; i++ { + h[hPtr+block*2*pePitchblock+i] = c[cout+i] / e[eout+i] + } + } + mask >>= 1 + } + } + + // Fine search. + var lagindsSurv [][NumSubframes]int32 + var blocksegsIxList []int + hComb := make([]float32, 2*pePitchblock) + lagindCache := make(map[int32]int32) + for _, idx := range trackIdx { + rng := tab.BlocksegsIx[idx] + for j := 0; j < rng[1]; j++ { + bsx := rng[0] + j + pblockseg := &tab.Blocksegs[bsx] + var lagindsRow [NumSubframes]int32 + startSf := 0 + for n := 0; n < pblockseg.Nblocks; n++ { + lookupKey := (((int32(startSf) << 3) + int32(pblockseg.Seglens[n])) << 4) | int32(pblockseg.Blocks[n]) + bestI, ok := lagindCache[lookupKey] + if !ok { + for v := range hComb { + hComb[v] = 0.0 + } + for sf := startSf; sf < startSf+pblockseg.Seglens[n]; sf++ { + hPtr := sf*2*pePitchblock*pitchNumBlocks + pblockseg.Blocks[n]*2*pePitchblock + for i := 0; i < 2*pePitchblock; i++ { + hComb[i] += h[hPtr+i] * e2[sf] + } + } + bestI = int32(peGetMaxi(hComb)) + lagindCache[lookupKey] = bestI + } + for sf := startSf; sf < startSf+pblockseg.Seglens[n]; sf++ { + lagindsRow[sf] = bestI + int32(pblockseg.Blocks[n]*2*pePitchblock) + } + startSf += pblockseg.Seglens[n] + } + lagindsSurv = append(lagindsSurv, lagindsRow) + blocksegsIxList = append(blocksegsIxList, bsx) + } + } + nlaginds := len(lagindsSurv) + + pitchRatewght := peRatewghtHr + if peLowRate { + pitchRatewght = 0.028 + } + f2w := BuildF2w(f2) + maxIx := peGetMaxi(sfWght[:numsubfrs]) + spectralHarmCache := make([]float32, 50) + + var bestUtil, bestPitchcorr float32 + bestSurv := 0 + pitchDeltawghtFs := pePitchDeltawght / float32(blocksizeFs) + + for surv := 0; surv < nlaginds; surv++ { + var sumC, sumE float32 + for sf := 0; sf < numsubfrs; sf++ { + cBase := offsetC + sf*strideC + eBase := offsetE + sf*strideE + li := int(lagindsSurv[surv][sf]) + sumC += c[cBase+li] + sumE += e[eBase+li] + } + rateBias := peEncodeLagsBits(tab, blocksegsIxList[surv], &lagindsSurv[surv], st.PrevLagblk, st.PrevLagidx, 1) * pitchRatewght + meanLag := float32(lagindsSurv[surv][maxIx])*0.5 + float32(peMinpitchLen) + pitchcorr := sumC / sumE + firstLag := 0.5*float32(lagindsSurv[surv][0]) + float32(peMinpitchLen) + prevLagBias := peGetPrevLagBias(st, firstLag) + spectralHarmBias := peSpecHarmBias * peSpectralHarmCached(meanLag, &f2w, spectralHarmCache, surv == 0) + util := 1.0/(1.1-pitchcorr) - pitchDeltawghtFs*float32(peSumdeltas(lagindsSurv[surv][:], numsubfrs)) + spectralHarmBias + prevLagBias - rateBias + if surv == 0 || util > bestUtil { + bestUtil = util + bestSurv = surv + } + if surv == 0 || pitchcorr > bestPitchcorr { + bestPitchcorr = pitchcorr + } + } + + var lags [NumSubframes]float32 + var lagindsOut [NumSubframes]int32 + for sf := 0; sf < numsubfrs; sf++ { + lags[sf] = float32(lagindsSurv[bestSurv][sf])*0.5 + float32(peMinpitchLen) + lagindsOut[sf] = lagindsSurv[bestSurv][sf] + } + avgLag := float32(lagindsSurv[bestSurv][maxIx])*0.5 + float32(peMinpitchLen) + harmStrength := peSpectralHarmCached(avgLag, &f2w, spectralHarmCache, false) + + st.PrevLag = lags[numsubfrs-1] + st.PrevPitchCorr = bestPitchcorr + st.PrevLagidx = lagindsSurv[bestSurv][numsubfrs-1] + st.PrevLagblk = st.PrevLagidx / int32(2*pePitchblock) + + return PitchResult{ + Pitchcorr: bestPitchcorr, + Lags: lags, + Laginds: lagindsOut, + AvgLag: avgLag, + HarmStrength: harmStrength, + BlocksegIdx: blocksegsIxList[bestSurv], + } +} diff --git a/pkg/call/voip/media/mlow/pitch_seed.bin b/pkg/call/voip/media/mlow/pitch_seed.bin new file mode 100644 index 0000000000000000000000000000000000000000..a70dfcdd5d8a507ea66d931ff6b84a8adad7bb8b GIT binary patch literal 2362 zcmV-A3B~q!+D%n?TvOK)cB0uKWFa903?YDoEdjzBBm@#55cYjb2wB)sHaA2?1r!yR zR$RapM2l1r6%=r(Rg0}FVpV>%eXVVud!Kcw?Q3h^x%kKH_q%h?opaBbJM+z%xs(I= z!zVJof9ANr|MBFC#U5PEUjyHs-7fljV`AN~_Y8_e*>gnrgFK>e&sf|08y*DV^Bd=f zUPh(pf86@7OmEM(KmYW!E!prQQ8(Cc_t0+RZ$Dm1c74NjwXLfP9nx22v9tozK7&Rq3y8NY*GX+Js8$!6s{#pImb zG`04J@18wUm<9bFV?Q^4caYI#RE|ckxbpHT1)n8jkNGwP7-s1LdTV0N{C1g-BR9TT z_jk|tYvy#zKE9B5<5fJx&S%iPu7CzTkoMJwNqp`@0p#9eP6}y<6&29N2B{ z54XOy_U2Eq{(7)_Lj1DuGq=7!qaJiBej5M!*>PI2{P6MHk6OLFcdz*N;R{`N^>c?` z9u3&P`MqPJeESPVse9h{`@Yp3Ti$o@tq*@2?vHlYV`cv~xI8Y^NDAKuF8y!Aru*XC zR}!U7f{vzNmOT8`CHukX$wzyw$KMUU6uGnQ*7|>L{^RP+mU>>QM|Co3^P7*}Z|=SN z=kn%B$%DQ_QKNCKSuLK$|6%y&F_Id!i~Z_*+b`)qy|Q#%k7a>I9o^yK}T zUq^n>J-z6vS-Z^RefvGWi}W7xZbZgC?veGwn(?zmoE;X*uD+1xTk2AsSKNm^-TXAM za%ASkp=O0jb~pAF_j%s}?fSS_S^27qQ_GtPCf_L5@7!C$)0YRYZ`Il>6{9cgpWv%Q zk21R3uVsh55#2wtX2uiWLYn3Foo|YoB^`ki!c&9!8plU-f*C$}cz(~JYyq>2oAz)6 z=ke(KK+zrgz^rXl1x=QBE_%=u9bnQAhG=Amgt;stm8=g}TNg@&m zcmm!U36sEK=}8145l?KyrR6c*laSH z3BE-vXLovyIRP^2#^GMGv$=g+UMEG;Q1E-p43QJkgIVH=I6lCtSJ zli6%G7GVKnO~6o8i1zCZXt!Q(D6Xt)Y~8wd|K?>iC3=jD?97a`wA9qJ^z`(Uq(n`; zCLu97IVmYQB_%m2L8H;ct7D?0Rfvmd6-HGg;wmCiDU*7O=D4}J2%Vjs1x`**d_E=^ zoCE@AXQ5E&;)?p02=y;-ABmqdFgP?^p^8mN&DB>m?)bv;`r6@+RSPSO1=%SHu_}dJ z77{4+^A-Ded3t!byCchPuC6XZ%#2Xz{J|D#h9VDSPV5&bQ$(Q-7!w<*K}btW&&bX#(3{HUFIw5wP;JajON>Jwjf{|^Jj=pRV$m296o}e%s7w|b z5)>2|AocU}^_7Sv5{X#sOmThOxn=Np0_x2A{Ynv`uraG&0)ZqaVk2yl8**tqLhi%JZG8uHbjSUUK8o}xx zu$~68N2fDbwssCeH+PYzr>B>X5Aw~|4}&k}oc;$*GaIj&Mm(Zelb}&YM=9jt7%S+&hhrs$ofy^hE>)~1HAgE;wN%N& z{KR6hr;EkM)s4+$Sc~MAM3I*x*VZj4z@irNW^=`{qF76uo4`?%lo(%S$&yRj*VHd+ g+-6y4>e{trcWc{5%Z8&nntC>^U%$ulKg8!r@OlWF0{{R3 literal 0 HcmV?d00001 diff --git a/pkg/call/voip/media/mlow/pitch_seed.go b/pkg/call/voip/media/mlow/pitch_seed.go new file mode 100644 index 00000000..e4fa493e --- /dev/null +++ b/pkg/call/voip/media/mlow/pitch_seed.go @@ -0,0 +1,256 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "bytes" + "compress/zlib" + _ "embed" + "io" +) + +// Build-from-seed for the MLow pitch runtime tables (port of smpl_pitch_seed.rs). +// The expanded PitchTables is the expansion of a small packed seed: the blocksegs +// bitstream (range-decoded), the index maps, and the DCMF arrays (integer CDF +// expansion). All integer — bit-exact with the reference. Replaces the larger +// smpl_pitch_tables.json blob. + +//go:embed pitch_seed.bin +var pitchSeedBlob []byte + +const ( + pitchNumBlocksegs = 217 + pitchNumBlocktracks = 187 +) + +// pitchSeed mirrors tables.proto PitchSeed (7 length-delimited byte fields). +type pitchSeed struct { + blocksegsBitstream []byte // range-decoder source + blocksegs2idx []byte // [217] + blocksegsIx []byte // [187][2] + firstblockRange []byte // [9][2] + blocksegIdxDcmf []byte // [217] + deltaLagDcmfs []byte // [3][319] + blockTransitionDcmf []byte // [9][9] +} + +// parseProtoBytes reads the length-delimited (wiretype 2) fields of a protobuf +// message into field-number → bytes. The pitch/cc seeds are all byte fields. +func parseProtoBytes(b []byte) map[int][]byte { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_tables_blob.rs#L26-L29 + out := make(map[int][]byte) + i := 0 + readVarint := func() (uint64, bool) { + var v uint64 + var shift uint + for i < len(b) { + c := b[i] + i++ + v |= uint64(c&0x7f) << shift + if c&0x80 == 0 { + return v, true + } + shift += 7 + } + return 0, false + } + for i < len(b) { + key, ok := readVarint() + if !ok { + break + } + field := int(key >> 3) + wire := int(key & 7) + if wire != 2 { + break // seeds are all length-delimited + } + ln, ok := readVarint() + if !ok || i+int(ln) > len(b) { + break + } + out[field] = b[i : i+int(ln)] + i += int(ln) + } + return out +} + +func loadPitchSeed() *pitchSeed { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L15-L31 + zr, err := zlib.NewReader(bytes.NewReader(pitchSeedBlob)) + if err != nil { + panic("mlow: inflate pitch seed: " + err.Error()) + } + raw, err := io.ReadAll(zr) + zr.Close() + if err != nil { + panic("mlow: read pitch seed: " + err.Error()) + } + f := parseProtoBytes(raw) + return &pitchSeed{ + blocksegsBitstream: f[1], + blocksegs2idx: f[2], + blocksegsIx: f[3], + firstblockRange: f[4], + blocksegIdxDcmf: f[5], + deltaLagDcmfs: f[6], + blockTransitionDcmf: f[7], + } +} + +// ecDecodeUniform decodes a uniform symbol in [0, n). +func ecDecodeUniform(dec *RangeDecoder, n uint32) uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L34-L38 + v := dec.Decode(n) + dec.Update(v, v+1, n) + return v +} + +// decodeBlockseg: len = uniform(6)+1, then len pairs of (uniform(9), uniform(4)+1). +func decodeBlockseg(dec *RangeDecoder) pitchBlockSeg { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L41-L57 + length := int(ecDecodeUniform(dec, 6) + 1) + blocks := make([]int, length) + seglens := make([]int, length) + for j := 0; j < length; j++ { + blocks[j] = int(ecDecodeUniform(dec, 9)) + seglens[j] = int(ecDecodeUniform(dec, 4) + 1) + } + return pitchBlockSeg{Nblocks: length, Blocks: blocks, Seglens: seglens} +} + +// genBlocktracks expands each track's blockseg into per-subframe track + mean/deltas. +func genBlocktracks(blocksegs []pitchBlockSeg, blocksegsIx [][2]int) []pitchBlockTrack { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L60-L86 + out := make([]pitchBlockTrack, 0, pitchNumBlocktracks) + for trackIdx := 0; trackIdx < pitchNumBlocktracks; trackIdx++ { + seg := &blocksegs[blocksegsIx[trackIdx][0]] + var track [NumSubframes]int + segIdx := 0 + var meanblock, trackdeltas float32 + for b := 0; b < seg.Nblocks; b++ { + for k := 0; k < seg.Seglens[b]; k++ { + track[segIdx] = seg.Blocks[b] + segIdx++ + } + meanblock += float32(seg.Blocks[b] * seg.Seglens[b]) + if b != 0 { + d := seg.Blocks[b-1] - seg.Blocks[b] + if d < 0 { + d = -d + } + trackdeltas += float32(d) + } + } + meanblock /= float32(NumSubframes) + out = append(out, pitchBlockTrack{Track: track, Meanblock: meanblock, Trackdeltas: trackdeltas}) + } + return out +} + +// pitchDcmfToCmf is the integer expansion of a DCMF to a cumulative CDF of length len+1. +func pitchDcmfToCmf(dcmf []byte) []uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L89-L109 + n := len(dcmf) + cmf := make([]uint32, n+1) + var sum int64 + for i := 0; i < n; i++ { + tmp := int32(dcmf[i]) + 1 + tmp *= tmp + if tmp > 65535 { + tmp = 65535 + } + cmf[i+1] = uint32(tmp) + sum += int64(tmp) + } + cmf[0] = 0 + for i := 1; i <= n; i++ { + prev := int64(cmf[i-1]) + add := int64(cmf[i])*(32767-int64(n))/sum + 1 + cmf[i] = uint32(prev + add) + } + return cmf +} + +func chunkPairs(b []byte) [][2]int { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L119-L128 + out := make([][2]int, 0, len(b)/2) + for i := 0; i+1 < len(b); i += 2 { + out = append(out, [2]int{int(b[i]), int(b[i+1])}) + } + return out +} + +// contourWindowParts is the pitch lag/contour heap window built from the same seed +// (port of smpl_pitch_seed.rs ContourWindowParts) — the tables Group D's pointer +// chase reads, laid out by mem.go at the fixed WASM addresses. +type contourWindowParts struct { + records [][2][]int // per contour: (blocks, seglens) + contourMap []byte // == blocksegs2idx + firstblockRange [][2]int + lagCdf []uint32 // dcmf_to_cmf(blockseg_idx_dcmf), 218 + fracCmfs [][]uint32 // 3 × 320 + deltaCmfs [][]uint32 // 9 × 10 +} + +// buildContourWindow re-decodes the blocksegs and expands the index maps + DCMFs. +func buildContourWindow() *contourWindowParts { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L178-L209 + s := loadPitchSeed() + dec := NewRangeDecoder(s.blocksegsBitstream) + records := make([][2][]int, 0, pitchNumBlocksegs) + for i := 0; i < pitchNumBlocksegs; i++ { + bs := decodeBlockseg(dec) + records = append(records, [2][]int{bs.Blocks, bs.Seglens}) + } + w := &contourWindowParts{ + records: records, + contourMap: s.blocksegs2idx, + firstblockRange: chunkPairs(s.firstblockRange), + lagCdf: pitchDcmfToCmf(s.blocksegIdxDcmf), + } + for i := 0; i+319 <= len(s.deltaLagDcmfs); i += 319 { + w.fracCmfs = append(w.fracCmfs, pitchDcmfToCmf(s.deltaLagDcmfs[i:i+319])) + } + for i := 0; i+pitchNumBlocks <= len(s.blockTransitionDcmf); i += pitchNumBlocks { + w.deltaCmfs = append(w.deltaCmfs, pitchDcmfToCmf(s.blockTransitionDcmf[i:i+pitchNumBlocks])) + } + return w +} + +// buildPitchTablesFromSeed expands the embedded seed into the full PitchTables. +func buildPitchTablesFromSeed() *PitchTables { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L111-L157 + s := loadPitchSeed() + dec := NewRangeDecoder(s.blocksegsBitstream) + blocksegs := make([]pitchBlockSeg, 0, pitchNumBlocksegs) + for i := 0; i < pitchNumBlocksegs; i++ { + blocksegs = append(blocksegs, decodeBlockseg(dec)) + } + blocksegsIx := chunkPairs(s.blocksegsIx) + firstblockRange := chunkPairs(s.firstblockRange) + blocktracks := genBlocktracks(blocksegs, blocksegsIx) + + blocksegs2idx := make([]int, len(s.blocksegs2idx)) + for i, x := range s.blocksegs2idx { + blocksegs2idx[i] = int(x) + } + blocksegIdxCmf := pitchDcmfToCmf(s.blocksegIdxDcmf) + deltaLagCmfs := make([][]uint32, 0, 3) + for i := 0; i+319 <= len(s.deltaLagDcmfs); i += 319 { + deltaLagCmfs = append(deltaLagCmfs, pitchDcmfToCmf(s.deltaLagDcmfs[i:i+319])) + } + blockTransitionCmf := make([][]uint32, 0, pitchNumBlocks) + for i := 0; i+pitchNumBlocks <= len(s.blockTransitionDcmf); i += pitchNumBlocks { + blockTransitionCmf = append(blockTransitionCmf, pitchDcmfToCmf(s.blockTransitionDcmf[i:i+pitchNumBlocks])) + } + + return &PitchTables{ + Blocksegs: blocksegs, + Blocktracks: blocktracks, + Blocksegs2idx: blocksegs2idx, + BlocksegIdxCmf: blocksegIdxCmf, + DeltaLagCmfs: deltaLagCmfs, + BlocksegsIx: blocksegsIx, + FirstblockRange: firstblockRange, + BlockTransitionCmf: blockTransitionCmf, + } +} diff --git a/pkg/call/voip/media/mlow/postfilter.go b/pkg/call/voip/media/mlow/postfilter.go new file mode 100644 index 00000000..897d37c8 --- /dev/null +++ b/pkg/call/voip/media/mlow/postfilter.go @@ -0,0 +1,534 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "math" + "sync" +) + +// Postfilters: the excitation-domain harmonic comb (func 3524), the post-LPC HP +// pitch-harmonic comb, and the per-packet harmonic postfilter. Validated +// end-to-end and via the hp/harm postfilter raw vectors when implemented. + +// --- excitation-domain harmonic comb (WASM func 3524) --- + +// SmplPostfilterState is the persistent comb-postfilter state (pitch gain, env, +// biquad/de-emphasis/resonator FIR state, smoothed autocorrelation, init/count/LCG). +type SmplPostfilterState struct { + EnvState float32 +} + +// SmplCombPostfilter computes the n-sample contribution the caller ADDS into the +// excitation. +func SmplCombPostfilter(st *SmplPostfilterState, input []float32, n int, active bool, gain8 float32, nrgEnv [2]float32, out []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_postfilter.rs#L249-L443 + // TODO + // agent suggestion: port smpl_comb_postfilter — per-subframe autocorrelation → + // resonator, de-emphasis FIR, env-shaped noise (LCG) add; carries biquad state. + // human input: + panic("mlow: SmplCombPostfilter not yet implemented (scaffold)") +} + +// --- post-LPC HP (pitch-harmonic) comb --- + +var loEmph = [2]float32{1.0, -0.995} + +const ( + hpPitchMAF = float32(0.1) + hpDefMAF = float32(0.1) + hpDefFcornerHz = float32(50.0) + lagChangeThreshold = float32(1.25) + hpPostfTransitionSpeed = float32(2.0) +) + +var ( + hpPitchARF = [2]float32{0.608057355, 0.070939485} + hpPitchARR = [2]float32{-2.187380512, 2.291030664} + hpDefARF = [2]float32{0.728508218, 0.476039848} + hpDefARR = [2]float32{-4.363803713, 8.441854006} +) + +// HpPostfilterState is the post-LPC HP comb state (C HpPst). lagOld < 0 marks a +// fresh/reset filter. +type HpPostfilterState struct { + stateLoEmph1 float32 + stateLoEmph2 float32 + stateHp [4]float32 // [ma2 x[-1], x[-2], ar2 y[-1], y[-2]] + lagOld float32 + xOld []float32 + coefMA [3]float32 + coefAR [3]float32 +} + +// NewHpPostfilterState allocates a fresh HP-postfilter state. +func NewHpPostfilterState() *HpPostfilterState { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L46-L58 + return &HpPostfilterState{lagOld: -1.0, xOld: make([]float32, SmplIntfLen)} +} + +func cosApprox(x float32) float32 { return 1.0 - 0.5*x*x } + +// SmplPfFir3 is the 3-tap FIR with carried 2-sample input history (smpl_filt_ma2 general). +func SmplPfFir3(input []float32, n int, coef [3]float32, state *[2]float32, out []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L68-L95 + xm1 := state[0] + xm2 := state[1] + for i := 0; i < n; i++ { + var p1, p2 float32 + if i >= 1 { + p1 = input[i-1] + } else { + p1 = xm1 + } + if i >= 2 { + p2 = input[i-2] + } else if i == 1 { + p2 = xm1 + } else { + p2 = xm2 + } + out[i] = coef[0]*input[i] + coef[1]*p1 + coef[2]*p2 + } + if n >= 2 { + state[0] = input[n-1] + state[1] = input[n-2] + } else if n == 1 { + state[1] = xm1 + state[0] = input[0] + } +} + +// pfFiltAR2: y[n] = in[n] - c1*y[n-1] - c2*y[n-2] (monic), 4-wide unrolled to match C rounding. +func pfFiltAR2(input []float32, n int, c1, c2 float32, state *[2]float32, out []float32) { + ytmp0 := state[1] + ytmp1 := state[0] + ar1 := -c1 + ar2 := -c2 + ar1_2 := ar1 * ar1 + ar1_3 := ar1 * ar1_2 + ar1_4 := ar1 * ar1_3 + imp1 := ar1 + imp2 := ar1_2 + ar2 + imp3 := ar1_3 + 2.0*ar1*ar2 + imp4 := ar1_4 + ar2*ar2 + 3.0*ar1_2*ar2 + ymp1 := ar2 + ymp2 := ar2 * imp1 + ymp3 := ar2 * imp2 + ymp4 := ar2 * imp3 + nn := 0 + for nn+3 < n { + xtmp0 := input[nn] + xtmp1 := input[nn+1] + xtmp2 := input[nn+2] + out[nn+2] = xtmp2 + imp1*xtmp1 + imp2*xtmp0 + imp3*ytmp1 + ymp3*ytmp0 + xtmp3 := input[nn+3] + out[nn+3] = xtmp3 + imp1*xtmp2 + imp2*xtmp1 + imp3*xtmp0 + imp4*ytmp1 + ymp4*ytmp0 + out[nn] = xtmp0 + imp1*ytmp1 + ymp1*ytmp0 + out[nn+1] = xtmp1 + imp1*xtmp0 + imp2*ytmp1 + ymp2*ytmp0 + ytmp0 = out[nn+2] + ytmp1 = out[nn+3] + nn += 4 + } + for nn < n { + out[nn] = input[nn] + ar1*ytmp1 + ar2*ytmp0 + ytmp0 = ytmp1 + ytmp1 = out[nn] + nn++ + } + state[1] = ytmp0 + state[0] = ytmp1 +} + +// pfFiltAR1: leaky integrator y[n] = x[n] - c1*y[n-1], 5-wide unrolled to match C rounding. +func pfFiltAR1(input []float32, n int, c1 float32, state *float32, out []float32) { + ar1 := -c1 + ar1_2 := ar1 * ar1 + ar1_3 := ar1 * ar1_2 + ar1_4 := ar1 * ar1_3 + ar1_5 := ar1 * ar1_4 + ytmp := *state + nn := 0 + for nn+4 < n { + xtmp0 := input[nn] + xtmp1 := input[nn+1] + xtmp2 := input[nn+2] + xtmp3 := input[nn+3] + xtmp4 := input[nn+4] + out[nn+4] = xtmp4 + ar1*xtmp3 + ar1_2*xtmp2 + ar1_3*xtmp1 + ar1_4*xtmp0 + ar1_5*ytmp + out[nn] = xtmp0 + ar1*ytmp + out[nn+1] = xtmp1 + ar1*xtmp0 + ar1_2*ytmp + out[nn+2] = xtmp2 + ar1*xtmp1 + ar1_2*xtmp0 + ar1_3*ytmp + out[nn+3] = xtmp3 + ar1*xtmp2 + ar1_2*xtmp1 + ar1_3*xtmp0 + ar1_4*ytmp + ytmp = out[nn+4] + nn += 5 + } + for nn < n { + ytmp = input[nn] + ytmp*ar1 + out[nn] = ytmp + nn++ + } + *state = ytmp +} + +// pfFiltMA1: y[n] = x[n] + c1*x[n-1] (companion pre-emphasis). +func pfFiltMA1(input []float32, n int, c1 float32, state *float32, out []float32) { + prev := *state + for i := n - 1; i >= 1; i-- { + out[i] = input[i] + c1*input[i-1] + } + if n > 0 { + out[0] = input[0] + c1*prev + *state = input[n-1] + } +} + +// SmplGetHpCoefs returns the default fixed-corner ARMA2 biquad (coefMA, coefAR). +func SmplGetHpCoefs(fcornerHz float32) (coefMA, coefAR [3]float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L188-L191 + fc := fcornerHz + if fc < 5.0 { + fc = 5.0 + } + if fc > 1500.0 { + fc = 1500.0 + } + return smplCalcHPCoefs(hpDefMAF, hpDefARF, hpDefARR, fc/16000.0) +} + +// SmplFiltArma2: MA2 numerator then AR2 denominator, shared 4-wide state. +func SmplFiltArma2(input []float32, n int, coefMA, coefAR [3]float32, state *[4]float32, out []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L194-L211 + tmp := make([]float32, n) + maSt := [2]float32{state[0], state[1]} + SmplPfFir3(input, n, coefMA, &maSt, tmp) + state[0] = maSt[0] + state[1] = maSt[1] + arSt := [2]float32{state[2], state[3]} + pfFiltAR2(tmp, n, coefAR[1], coefAR[2], &arSt, out) + state[2] = arSt[0] + state[3] = arSt[1] +} + +// smplCalcHPCoefs builds the unity-DC comb biquad: AR resonance at the pitch angle +// 2*pi*arf*f with radius 1+arr*f, then MA scaled for unity DC gain. +func smplCalcHPCoefs(maf float32, arf, arr [2]float32, f float32) (coefMA, coefAR [3]float32) { + coefMA = [3]float32{1.0, -2.0 * cosApprox(2.0*smplPiF32*maf*f), 1.0} + far := arf[0]*f + arf[1]*f*f + rar := arr[0]*f + arr[1]*f*f + coefAR = [3]float32{ + 1.0, + -2.0 * cosApprox(2.0*smplPiF32*far) * (1.0 + rar), + 1.0 + (2.0*rar + rar*rar), + } + sc := (1.0 - coefAR[1] + coefAR[2]) / (1.0 - coefMA[1] + coefMA[2]) + coefMA[0] *= sc + coefMA[1] *= sc + coefMA[2] *= sc + return coefMA, coefAR +} + +// newCoefs: voiced pitch curve when lag>0 (f=1/lag), else the default 50 Hz curve. +func newCoefs(st *HpPostfilterState, lag float32) { + if lag > 0.0 { + st.coefMA, st.coefAR = smplCalcHPCoefs(hpPitchMAF, hpPitchARF, hpPitchARR, 1.0/lag) + } else { + fc := hpDefFcornerHz // already in [5,1500] + st.coefMA, st.coefAR = smplCalcHPCoefs(hpDefMAF, hpDefARF, hpDefARR, fc/16000.0) + } +} + +// rampDn is the cos(omega)^2 down-ramp for the lag-change overlap-add. +func rampDn() []float32 { + rampDnOnce.Do(func() { + dOmega := smplPiF32 / (2.0 * (float32(SmplIntfLen) + 1.0)) + omega := dOmega + rampDnTab = make([]float32, SmplIntfLen) + for i := 0; i < SmplIntfLen; i++ { + rampDnTab[i] = float32(math.Pow(float64(float32(math.Cos(float64(omega)))), float64(hpPostfTransitionSpeed))) + omega += dOmega + } + }) + return rampDnTab +} + +var ( + rampDnOnce sync.Once + rampDnTab []float32 +) + +// SmplHpPostfilter applies the post-LPC HP comb; lag is the frame's average pitch +// lag (sum(l^2)/sum(l)), 0 for unvoiced. +func SmplHpPostfilter(st *HpPostfilterState, xIn []float32, n int, lag float32, out []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L265-L314 + x := make([]float32, n) + pfFiltAR1(xIn, n, loEmph[1], &st.stateLoEmph1, x) + + overlap := false + yOld := make([]float32, n) + if st.lagOld < 0.0 { + newCoefs(st, lag) + st.lagOld = lag + } else if lag > lagChangeThreshold*st.lagOld || lagChangeThreshold*lag < st.lagOld { + overlap = true + SmplFiltArma2(x, n, st.coefMA, st.coefAR, &st.stateHp, yOld) + newCoefs(st, lag) + st.lagOld = lag + xOld := append([]float32(nil), st.xOld...) + dummy := make([]float32, n) + SmplFiltArma2(xOld, n, st.coefMA, st.coefAR, &st.stateHp, dummy) + } else if lag != st.lagOld { + newCoefs(st, lag) + st.lagOld = lag + } + copy(st.xOld[:n], x[:n]) + + yTmp := make([]float32, n) + SmplFiltArma2(x, n, st.coefMA, st.coefAR, &st.stateHp, yTmp) + + if overlap { + ramp := rampDn() + for i := 0; i < n; i++ { + yTmp[i] += (yOld[i] - yTmp[i]) * ramp[i] + } + } + + pfFiltMA1(yTmp, n, loEmph[1], &st.stateLoEmph2, out) +} + +// --- per-packet harmonic postfilter (smpl_harm_postfilter.c) --- + +const ( + harmMaxFramesPerPacket = 6 + harmMinPitchLag = 32 + harmMaxPitchLag = 320 + harmMaxpitchLen = 320 + harmFBDelay = 8 + harmLagSubfrLen = 40 + harmDelay = 40 // = LAG_SUBFR_LEN + harmPitchNumSubframes = 8 + harmFBStrength = float32(0.4734) + harmStrength = float32(0.6438) + harmCutoffHz = float32(4000.0) + harmNHarmCutoff = float32(6.3) + harmReductionFac = float32(0.0579) + harmLPFiltRes = 2500 + harmStateCombLen = harmMaxpitchLen + SmplIntfLen*harmMaxFramesPerPacket + harmDelay + harmNumLPFilt = harmLPFiltRes/80 - harmLPFiltRes/harmMaxPitchLag + 1 +) + +func lagToFiltIx(lag int32) int { + d := lag + 30 + if d < 80 { + d = 80 + } + return int(int32(harmLPFiltRes)/d - int32(harmLPFiltRes)/int32(harmMaxPitchLag)) +} + +type harmTablesT struct { + lpFilters [][2*harmFBDelay + 1]float32 +} + +var ( + harmTablesOnce sync.Once + harmTablesV harmTablesT +) + +func harmTables() *harmTablesT { + harmTablesOnce.Do(func() { + var filtWin [harmFBDelay]float32 + dOmega := (0.5 * smplPiF32) / (float32(harmFBDelay) + 1.0) + omega := dOmega + for i := 0; i < harmFBDelay; i++ { + filtWin[i] = float32(math.Cos(float64(omega))) / (float32(i) + 1.0) + omega += dOmega + } + harmTablesV.lpFilters = make([][2*harmFBDelay + 1]float32, harmNumLPFilt) + ixPrev := int32(-1) + for lag := int32(harmMinPitchLag); lag <= harmMaxPitchLag; lag++ { + ix := int32(lagToFiltIx(lag)) + if ix != ixPrev { + harmCreateLPFilter(2.0*smplPiF32/float32(lag), &filtWin, &harmTablesV.lpFilters[ix]) + ixPrev = ix + } + } + }) + return &harmTablesV +} + +func harmCreateLPFilter(omega0 float32, filtWin *[harmFBDelay]float32, blp *[2*harmFBDelay + 1]float32) { + omegaC := omega0 * harmNHarmCutoff + if lim := harmCutoffHz / 16000.0 * smplPiF32; lim < omegaC { + omegaC = lim + } + var sumB float32 + omegaCSum := omegaC + for i := 0; i < harmFBDelay; i++ { + b := filtWin[i] * float32(math.Sin(float64(omegaCSum))) + omegaCSum += omegaC + blp[harmFBDelay+i+1] = b + blp[harmFBDelay-i-1] = b + sumB += 2.0 * b + } + blp[harmFBDelay] = omegaC + sumB += omegaC + sc := 1.0 / sumB + for k := range blp { + blp[k] *= sc + } +} + +// HarmPostfilterState is the per-packet harmonic postfilter state (C HarmPst). +type HarmPostfilterState struct { + state1 [2 * harmFBDelay]float32 + lpcoefs [2*harmFBDelay + 1]float32 + stateComb []float32 + prevLag int32 + prevDidFilter int32 +} + +// NewHarmPostfilterState allocates a fresh harmonic-postfilter state. +func NewHarmPostfilterState() *HarmPostfilterState { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harm_postfilter.rs#L102-L112 + return &HarmPostfilterState{stateComb: make([]float32, harmStateCombLen)} +} + +func harmDotProd(a, b []float32, l int) float32 { + var r float32 + for i := 0; i < l; i++ { + r += a[i] * b[i] + } + return r +} + +func harmNrg(x []float32, n int) float32 { + var r float32 + for i := 0; i < n; i++ { + r += x[i] * x[i] + } + return r +} + +// harmFiltMA16Sym: 17-tap symmetric MA reading 16 samples of history from buf[xBase-16..]. +func harmFiltMA16Sym(buf []float32, xBase, n int, coef *[17]float32, out []float32) { + for nn := 0; nn < n; nn++ { + c := xBase + nn + res := buf[c-8] * coef[8] + for i := 0; i < 8; i++ { + res += coef[i] * (buf[c-i] + buf[c-16+i]) + } + out[nn] = res + } +} + +// harmPostfilterCore filters one 40-sample lag block (harm_postfilter_core). +func harmPostfilterCore(lpcoefs *[2*harmFBDelay + 1]float32, comb []float32, combX int, futureSamples int32, lag int32, diff []float32, diffBase int, out []float32, outOff, l int, fbStrength float32, prevDidFilter *int32) { + tables := harmTables() + lagU := int(lag) + var xy float32 + if lag > 0 { + lookforward := int32(l) + lag - futureSamples + if lookforward > 0 { + l2 := int(int32(l) - lookforward) + if l2 < 0 { + l2 = 0 + } + for i := 0; i < l2; i++ { + out[outOff+i] = comb[combX+i-lagU] + comb[combX+i+lagU] + } + for i := 0; i < l-l2; i++ { + out[outOff+l2+i] = comb[combX+l2+i-lagU] + comb[combX+l2+i] + } + } else { + for i := 0; i < l; i++ { + out[outOff+i] = comb[combX+i-lagU] + comb[combX+i+lagU] + } + } + xy = harmDotProd(comb[combX:], out[outOff:], l) + } + if lag > 0 && xy > 0.0 { + xx := harmNrg(comb[combX:], l) + yy := 0.25 * harmNrg(out[outOff:], l) + denom := yy + if xx > denom { + denom = xx + } + strength := 0.5 * xy / denom + highLagReduction := 1.0 - harmReductionFac*(float32(lag-harmMinPitchLag)/float32(harmMaxPitchLag-harmMinPitchLag)) + strength = strength * highLagReduction * harmStrength + for i := 0; i < l; i++ { + out[outOff+i] *= 0.5 * strength + } + for i := 0; i < l; i++ { + diff[diffBase+i] = out[outOff+i] + (-strength)*comb[combX+i] + } + kernel := tables.lpFilters[lagToFiltIx(lag)] + for k := 0; k < 2*harmFBDelay+1; k++ { + lpcoefs[k] = kernel[k] * fbStrength + } + coef17 := *lpcoefs + var yh [harmLagSubfrLen]float32 + harmFiltMA16Sym(diff, diffBase, l, &coef17, yh[:]) + for i := 0; i < l; i++ { + out[outOff+i] = yh[i] + comb[combX-harmFBDelay+i] + } + *prevDidFilter = 1 + } else { + for i := 0; i < harmLagSubfrLen; i++ { + diff[diffBase+i] = 0.0 + } + if *prevDidFilter != 0 { + coef17 := *lpcoefs + var yh [2 * harmFBDelay]float32 + harmFiltMA16Sym(diff, diffBase, 2*harmFBDelay, &coef17, yh[:]) + for i := 0; i < 2*harmFBDelay; i++ { + out[outOff+i] = yh[i] + comb[combX-harmFBDelay+i] + } + for i := 2 * harmFBDelay; i < l; i++ { + out[outOff+i] = comb[combX+harmFBDelay+i-2*harmFBDelay] + } + } else { + for i := 0; i < l; i++ { + out[outOff+i] = comb[combX-harmFBDelay+i] + } + } + *prevDidFilter = 0 + } +} + +// SmplHarmPostfilter applies the harmonic postfilter to a full packet IN PLACE. x is +// xLen samples; lags are the per-40-block lags (nLags = packetlen/40); +// normalizedBitrate is the packet average. +func SmplHarmPostfilter(st *HarmPostfilterState, x []float32, xLen int, lags []float32, nLags int, normalizedBitrate float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harm_postfilter.rs#L242-L299 + const diffPrefix = 2 * harmFBDelay // 16 samples of history prefix + diff := make([]float32, SmplIntfLen+diffPrefix) + + lag := st.prevLag + combCur := harmMaxPitchLag + harmDelay // current packet starts here + copy(st.stateComb[combCur:combCur+xLen], x[:xLen]) + + fbStrength := 1.0 - harmFBStrength*normalizedBitrate + offset1 := 0 + lagCtr := 0 + for lagCtr < nLags { + offset2 := 0 + copy(diff[diffPrefix-16:diffPrefix], st.state1[:]) + lagCtrEnd := lagCtr + harmPitchNumSubframes + if lagCtrEnd > nLags { + lagCtrEnd = nLags + } + for lagCtr < lagCtrEnd { + combX := harmMaxPitchLag + offset1 + futureSamples := int32(harmDelay) + int32(xLen) - int32(offset1) + harmPostfilterCore(&st.lpcoefs, st.stateComb, combX, futureSamples, lag, diff, diffPrefix+offset2, x, offset1, harmLagSubfrLen, fbStrength, &st.prevDidFilter) + offset1 += harmLagSubfrLen + offset2 += harmLagSubfrLen + lag = int32(math.Round(float64(lags[lagCtr]))) + lagCtr++ + } + copy(st.state1[:], diff[diffPrefix+offset2-16:diffPrefix+offset2]) + } + + st.prevLag = lag + copy(st.stateComb[0:combCur], st.stateComb[xLen:xLen+combCur]) +} diff --git a/pkg/call/voip/media/mlow/pulse.go b/pkg/call/voip/media/mlow/pulse.go new file mode 100644 index 00000000..f0f11132 --- /dev/null +++ b/pkg/call/voip/media/mlow/pulse.go @@ -0,0 +1,234 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +// Excitation pulse decode (PVQ-style) for one internal frame: the total pulse +// count, the recursive split across subframes, the per-position magnitudes, and the +// signs — read straight from the range-coded bitstream against the heap-window ROM. + +// smplPulseCountByte is the static gain-helper table at rodata 0xe8990, indexed by +// [config*3 + (p4+s1)]. Verbatim from the reference. +var smplPulseCountByte = [8]uint8{80, 160, 160, 16, 32, 32, 0, 0} + +// Mem8Static reads the one static rodata table the pulse path needs (0xe8990..0xe8998); +// every other address reads as 0. +func Mem8Static(addr uint32) byte { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pulse.rs#L13-L19 + if addr >= 0xe8990 && addr < 0xe8998 { + return smplPulseCountByte[addr-0xe8990] + } + return 0 +} + +// SmplPulseResult is the decoded excitation for one internal (20 ms) frame. +type SmplPulseResult struct { + Pulses []int32 // signed pulse magnitudes per sample position (len = p2) + Subfr [4]int32 // per-subframe pulse counts + // Raw entropy symbols (for the encoder to replay byte-exactly): the per-position + // run-length magnitude symbols and the batched raw sign symbols, in read order. + MagRuns []int32 + SignSyms []SmplRawSym +} + +// DecodeSmplPulses decodes the pulse blocks of one internal frame. p2 = frame +// samples (320), p3 = num subframes (4), p4 = regular flag (1), p6 = config (0/1), +// s1 = LSF stage-1 selector. +func DecodeSmplPulses(dec *RangeDecoder, _ *SmplMem, p2, p3, p4, p6, s1 int32) SmplPulseResult { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pulse.rs#L29-L206 + n := p2 + if n < 0 { + n = 0 + } + res := SmplPulseResult{Pulses: make([]int32, n)} + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_pulse.rs#L26-L185 (seed cc-table rewire: count/split/runlen from CcTables) + cc := LoadCcTables() + + idx := p4 + s1 + bByte := int32(Mem8Static(0xe8990 + uint32(p6*3+idx))) + frameLen4k := bByte * p2 / 320 + // ASSUMPTION: p3 (subframe count) is nonzero — always 4 on the 1:1 decode path. + // p3==0 divides by zero exactly as the reference does (a malformed-frame crash); + // we don't add a guard the reference lacks, to stay bit-faithful. + subfrLen16 := frameLen4k / p3 + posPerSubfr := p2 / p3 + + // --- pulse COUNT --- + var total int32 + if p6 != 0 { + // WB low-rate: the pulse-count CDF for this voicing class. + total = dec.DecodeCDF(cc.NPulseCount(idx)) + } else { + // NB (config=0, our path): a TRIANGULAR prior over [0, frame_len4k]. + l := uint32(frameLen4k) + triT := func(k uint32) uint32 { + a := (k + 2) * (l + 1) + b := ((k - 1) * (k + 131070)) >> 1 + return (a - b) & 0xffff + } + ft := triT(l) + if ft == 0 { + ft = 1 + } + val := dec.Decode(ft) + limit := uint32(frameLen4k) + 1 + var prevCum uint32 + var k uint32 + for { + if k == limit { + break + } + cum := triT(k) + // found when prevCum <= val < cum (the cumulative-triangular interval). + if prevCum <= val && val < cum { + dec.Update(prevCum, cum, ft) + break + } + prevCum = cum + k++ + } + total = int32(k) + } + + // --- recursive binary SPLIT (p3==4 path) --- + var split [8]int32 + if total != 0 { + sum := total - subfrLen16*2 + if sum < 0 { + sum = 0 + } + lo := total - 80 + if lo < 0 { + lo = 0 + } + if sum < lo { + // min_split2 >= min_split assert path; treat as parse error (zeroed subframes). + return res + } + hiBound := total - lo + if sum < hiBound { + // window the split CDF at (sum - lo); n entries from the table base. + sum += dec.DecodeCDF(cdfWindow(cc.SplitCmf(total), int(sum-lo), int((hiBound-sum)+2))) + } + if sum > 0 { + s0 := smplSplit3537(dec, cc, sum, subfrLen16) + split[0] = s0 + split[1] = sum - s0 + } + if sum < total { + s2 := smplSplit3537(dec, cc, total-sum, subfrLen16) + split[2] = s2 + split[3] = (total - sum) - s2 + } + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/543302e762ef36913b3e2fdf7f84510c43265272/wacore/src/voip/mlow/smpl_pulse.rs#L109-L113 (upstream corrupt-split guard) + // C smpl_pulse_coding zeroes the whole split (and n_pulses) on a corrupt -1 + // from either half, rather than copying the sentinel into res.Subfr. + if split[0] == -1 || split[2] == -1 { + split = [8]int32{} + } + } + + take := p3 + if take < 0 { + take = 0 + } + if take > 4 { + take = 4 + } + copy(res.Subfr[:take], split[:take]) + + // --- MAGNITUDE block: per-subframe run-length pulse positions --- + posPer := posPerSubfr + var posList []int32 + var magList []int32 + pulseIdx := int32(-1) + for subfr := int32(0); subfr < p3; subfr++ { + cnt := split[subfr] + if cnt <= 0 { + continue + } + basePos := posPer * subfr + runPos := basePos + pos := posPer + c := cnt + k := int32(0) + for k < cnt { + if pos < 0 { + break // defensive: malformed frame must not drive a huge CDF length + } + oct := (pos + 7) / 8 + // window the c-pulses run-length CDF by (max_samples - pos), reading pos+1 entries. + bucket := cc.Runlen(oct) + start := int(bucket.MaxSamples() - pos) + m := dec.DecodeCDF(cdfWindow(bucket.Cmf(c), start, int(pos+1))) + res.MagRuns = append(res.MagRuns, m) + if m > 0 || k == 0 { + pulseIdx++ + runPos += m + posList = append(posList, runPos) + magList = append(magList, 1) + pos -= m + } else if pulseIdx >= 0 { + magList[pulseIdx]++ + } + c-- + k++ + } + } + + numPos := pulseIdx + 1 + + // --- SIGN block: batched uniform sign reads (1 bit per position) --- + if numPos > 0 { + p := int32(0) + for p <= pulseIdx { + nbits := numPos - p + if nbits >= 15 { + nbits = 15 + } + if nbits <= 0 { + break + } + sym := dec.DecodeRawSymbol(uint32(nbits)) + res.SignSyms = append(res.SignSyms, SmplRawSym{Sym: sym, Nbits: uint32(nbits)}) + bitfield := sym << uint32(16-nbits) + end := p + nbits + for q := p; q < end; q++ { + sign := int32((bitfield>>14)&2) - 1 // +1 if MSB set else -1 + magList[q] *= sign + bitfield <<= 1 + } + p = end + } + } + + // scatter signed magnitudes into the pulse vector at their absolute positions. + for i := int32(0); i < numPos; i++ { + pp := posList[i] + if pp >= 0 && int(pp) < len(res.Pulses) { + res.Pulses[pp] = magList[i] + } + } + return res +} + +// smplSplit3537 splits count pulses across a range, returning the count assigned to +// the first half (func 3537). The split CDF now comes from the seed-built CcTables. +func smplSplit3537(dec *RangeDecoder, cc *CcTables, count, granularity int32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pulse.rs#L208-L230 + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_pulse.rs#L188-L201 (seed cc-table rewire: SplitCmf) + lo := count + if granularity < lo { + lo = granularity + } + minSplit := count - granularity + if minSplit < 0 { + minSplit = 0 + } + if lo < minSplit { + return -1 + } + if minSplit == lo { + return minSplit + } + n := int((lo - minSplit) + 2) + return dec.DecodeCDF(cdfWindow(cc.SplitCmf(count), int(minSplit), n)) + minSplit +} diff --git a/pkg/call/voip/media/mlow/rangecoder.go b/pkg/call/voip/media/mlow/rangecoder.go new file mode 100644 index 00000000..629e4d47 --- /dev/null +++ b/pkg/call/voip/media/mlow/rangecoder.go @@ -0,0 +1,549 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "math/bits" + +const ( + ecSymBits = 8 + ecCodeBits = 32 + ecSymMax = 255 + ecCodeTop = 1 << (ecCodeBits - 1) + ecCodeBot = ecCodeTop >> ecSymBits + ecCodeExtra = (ecCodeBits-2)%ecSymBits + 1 + ecWindowSize = 32 + ecUintBits = 8 + ecCodeShift = ecCodeBits - ecSymBits - 1 +) + +// ilog is floor(log2(x))+1 for x>0 and 0 for x==0. +func ilog(x uint32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L24-L26 + return int32(bits.Len32(x)) +} + +func ecMini(a, b uint32) uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L29-L31 + if a < b { + return a + } + return b +} + +// RangeDecoder is the Opus/CELT range entropy decoder. Range-coded symbols are +// read from the front of the buffer, raw bits from the back. +type RangeDecoder struct { + buf []byte + storage uint32 + endOffs uint32 + endWindow uint32 + nendBits int32 + nbitsTotal int32 + offs uint32 + rng uint32 + val uint32 + ext uint32 + rem int32 + // Err is a sticky decode error (degenerate/malformed table or exhausted bits). + Err int32 +} + +// NewRangeDecoder initializes a decoder over buf. +func NewRangeDecoder(buf []byte) *RangeDecoder { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L52-L72 + d := &RangeDecoder{ + buf: buf, + storage: uint32(len(buf)), + nbitsTotal: ecCodeBits + 1 - ((ecCodeBits-ecCodeExtra)/ecSymBits)*ecSymBits, + rng: 1 << ecCodeExtra, + } + d.rem = int32(d.readByte()) + d.val = d.rng - 1 - uint32(d.rem>>(ecSymBits-ecCodeExtra)) + d.normalize() + return d +} + +func (d *RangeDecoder) readByte() uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L74-L82 + if d.offs < d.storage { + b := d.buf[d.offs] + d.offs++ + return uint32(b) + } + return 0 +} + +func (d *RangeDecoder) readByteFromEnd() uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L84-L91 + if d.endOffs < d.storage { + d.endOffs++ + return uint32(d.buf[d.storage-d.endOffs]) + } + return 0 +} + +func (d *RangeDecoder) normalize() { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L93-L106 + for d.rng <= ecCodeBot { + d.nbitsTotal += ecSymBits + d.rng <<= ecSymBits + sym0 := d.rem + d.rem = int32(d.readByte()) + sym := (sym0<> (ecSymBits - ecCodeExtra) + d.val = (d.val<> bitsN + if d.ext == 0 { + d.Err = 1 + d.ext = 1 + return 0 + } + s := d.val / d.ext + ft := uint32(1) << bitsN + return ft - ecMini(s+1, ft) +} + +// DecodeRawSymbol decodes a uniform nbits-bit symbol directly off the range stream. +func (d *RangeDecoder) DecodeRawSymbol(nbits uint32) uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L141-L145 + sym := d.decodeBin(nbits) + d.Update(sym, sym+1, uint32(1)< 0 { + d.rng = d.ext * (fh - fl) + } else { + d.rng -= s + } + d.normalize() +} + +// BitLogp decodes one bit with P(0) = 1/2^logp. +func (d *RangeDecoder) BitLogp(logp uint32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L160-L173 + r := d.rng + dv := d.val + s := r >> logp + var ret int32 + if dv < s { + ret = 1 + } + if ret == 0 { + d.val = dv - s + d.rng = r - s + } else { + d.rng = s + } + d.normalize() + return ret +} + +// DecodeICDF decodes a symbol against an inverse-CDF table; ftb = log2(ft). +func (d *RangeDecoder) DecodeICDF(icdf []byte, ftb uint32) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L176-L199 + if len(icdf) == 0 { + d.Err = 1 + return 0 + } + s0 := d.rng + dv := d.val + r := s0 >> ftb + ret := int32(-1) + var t uint32 + s := s0 + for { + t = s + ret++ + s = r * uint32(icdf[ret]) + if dv >= s || int(ret) >= len(icdf)-1 { + break + } + } + d.val = dv - s + d.rng = t - s + d.normalize() + return ret +} + +// DecodeCDF decodes a symbol against a uint16 cumulative CDF table; the effective +// total is cdf[n-1]-cdf[0]. +func (d *RangeDecoder) DecodeCDF(cdf []uint16) int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L203-L226 + n := len(cdf) + if n < 2 { + d.Err = 1 + return 0 + } + base := uint32(cdf[0]) + if uint32(cdf[n-1]) <= base { + d.Err = 1 + return 0 + } + ft := uint32(cdf[n-1]) - base + fs := d.Decode(ft) + target := base + fs + k := 0 + for k < n-1 { + if uint32(cdf[k+1]) > target { + break + } + k++ + } + d.Update(uint32(cdf[k])-base, uint32(cdf[k+1])-base, ft) + return int32(k) +} + +// BitsN reads n raw bits from the back of the buffer, LSB-first. +func (d *RangeDecoder) BitsN(n uint32) uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L229-L248 + window := d.endWindow + available := d.nendBits + if uint32(available) < n { + for { + window |= d.readByteFromEnd() << uint32(available) + available += ecSymBits + if uint32(available) > ecWindowSize-ecSymBits { + break + } + } + } + ret := window & ((uint32(1) << n) - 1) + window >>= n + available -= int32(n) + d.endWindow = window + d.nendBits = available + d.nbitsTotal += int32(n) + return ret +} + +// DecodeUint decodes an integer uniformly distributed in [0, ft0) for ft0 > 1. +func (d *RangeDecoder) DecodeUint(ft0 uint32) uint32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L251-L270 + ft := ft0 - 1 + ftb := ilog(ft) + if ftb > ecUintBits { + ftb -= ecUintBits + t := (ft >> uint32(ftb)) + 1 + s := d.Decode(t) + d.Update(s, s+1, t) + v := (s << uint32(ftb)) | d.BitsN(uint32(ftb)) + if v <= ft { + return v + } + d.Err = 1 + return ft + } + ft++ + s := d.Decode(ft) + d.Update(s, s+1, ft) + return s +} + +// Decode64FineSym decodes the 64-symbol uniform fine-lag value. +func (d *RangeDecoder) Decode64FineSym() int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L274-L285 + d.ext = d.rng >> 6 + if d.ext == 0 { + d.Err = 1 + d.ext = 1 + return 0 + } + s := d.val / d.ext + sym := int64(63) - int64(s) + if sym < 0 { + sym = 0 + } else if sym > 64 { + sym = 64 + } + d.Update(uint32(sym), uint32(sym)+1, 64) + return int32(sym) +} + +// Tell reports the number of bits consumed so far, rounded up. +func (d *RangeDecoder) Tell() int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L289-L291 + return d.nbitsTotal - ilog(d.rng) +} + +// RangeEncoder is the Opus/CELT range entropy encoder, the exact inverse of +// RangeDecoder. Range-coded symbols are written toward the front of the buffer, +// raw bits toward the back; Done flushes and merges them. +type RangeEncoder struct { + buf []byte + storage uint32 + endOffs uint32 + endWindow uint32 + nendBits int32 + nbitsTotal int32 + offs uint32 + rng uint32 + val uint32 + ext uint32 + rem int32 + err int32 +} + +// NewRangeEncoder allocates an encoder writing into a size-byte buffer. +func NewRangeEncoder(size int) *RangeEncoder { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L313-L328 + return &RangeEncoder{ + buf: make([]byte, size), + storage: uint32(size), + nbitsTotal: ecCodeBits + 1, + rng: ecCodeTop, + rem: -1, + } +} + +// Err returns the sticky encode error (-1 on failure). +func (e *RangeEncoder) Err() int32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L330-L332 + return e.err +} + +func (e *RangeEncoder) writeByte(b uint32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L334-L341 + if e.offs+e.endOffs < e.storage { + e.buf[e.offs] = byte(b) + e.offs++ + } else { + e.err = -1 + } +} + +func (e *RangeEncoder) writeByteAtEnd(b uint32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L343-L350 + if e.offs+e.endOffs < e.storage { + e.endOffs++ + e.buf[e.storage-e.endOffs] = byte(b) + } else { + e.err = -1 + } +} + +func (e *RangeEncoder) carryOut(c int32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L352-L372 + if uint32(c) != ecSymMax { + carry := c >> ecSymBits + if e.rem >= 0 { + e.writeByte(uint32(e.rem + carry)) + } + if e.ext > 0 { + sym := uint32((ecSymMax + carry) & ecSymMax) + for { + e.writeByte(sym) + e.ext-- + if e.ext == 0 { + break + } + } + } + e.rem = c & ecSymMax + } else { + e.ext++ + } +} + +func (e *RangeEncoder) normalize() { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L374-L381 + for e.rng <= ecCodeBot { + e.carryOut(int32(e.val >> ecCodeShift)) + e.val = (e.val << ecSymBits) & (ecCodeTop - 1) + e.rng <<= ecSymBits + e.nbitsTotal += ecSymBits + } +} + +// Encode encodes the symbol with cumulative range [fl, fh) out of ft. +func (e *RangeEncoder) Encode(fl, fh, ft uint32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L383-L398 + if ft == 0 { + e.err = -1 + return + } + r := e.rng / ft + if fl > 0 { + e.val += e.rng - r*(ft-fl) + e.rng = r * (fh - fl) + } else { + e.rng -= r * (ft - fh) + } + e.normalize() +} + +// BitLogp encodes one bit with P(0) = 1/2^logp. +func (e *RangeEncoder) BitLogp(val int32, logp uint32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L400-L412 + r := e.rng + l := e.val + s := r >> logp + r2 := r - s + if val != 0 { + e.val = l + r2 + e.rng = s + } else { + e.rng = r2 + } + e.normalize() +} + +// EncodeICDF encodes symbol s against an inverse-CDF table; ftb = log2(ft). +func (e *RangeEncoder) EncodeICDF(s int32, icdf []byte, ftb uint32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L414-L428 + r := e.rng >> ftb + if s > 0 { + e.val += e.rng - r*uint32(icdf[s-1]) + e.rng = r * uint32(icdf[s-1]-icdf[s]) + } else { + e.rng -= r * uint32(icdf[s]) + } + e.normalize() +} + +// EncodeCDF encodes symbol s against a uint16 cumulative CDF table. +func (e *RangeEncoder) EncodeCDF(s int32, cdf []uint16) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L431-L448 + n := len(cdf) + if n < 2 || s < 0 || int(s+1) >= n { + e.err = -1 + return + } + base := uint32(cdf[0]) + if uint32(cdf[n-1]) <= base { + e.err = -1 + return + } + ft := uint32(cdf[n-1]) - base + e.Encode(uint32(cdf[s])-base, uint32(cdf[s+1])-base, ft) +} + +// BitsN writes the low n bits of fl as raw bits toward the back of the buffer. +func (e *RangeEncoder) BitsN(fl, n uint32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L451-L469 + window := e.endWindow + used := e.nendBits + if used+int32(n) > ecWindowSize { + for { + e.writeByteAtEnd(window & ecSymMax) + window >>= ecSymBits + used -= ecSymBits + if used < ecSymBits { + break + } + } + } + window |= fl << uint32(used) + used += int32(n) + e.endWindow = window + e.nendBits = used + e.nbitsTotal += int32(n) +} + +// EncodeUint encodes an integer uniformly distributed in [0, ft0). +func (e *RangeEncoder) EncodeUint(fl, ft0 uint32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L471-L482 + ft := ft0 - 1 + ftb := ilog(ft) + if ftb > ecUintBits { + shift := uint32(ftb - ecUintBits) + t := (ft >> shift) + 1 + e.Encode(fl>>shift, (fl>>shift)+1, t) + e.BitsN(fl&((uint32(1)<> uint32(l) + end := (e.val + msk) &^ msk + if (end | msk) >= e.val+e.rng { + l++ + msk >>= 1 + end = (e.val + msk) &^ msk + } + for l > 0 { + e.carryOut(int32(end >> ecCodeShift)) + end = (end << ecSymBits) & (ecCodeTop - 1) + l -= ecSymBits + } + if e.rem >= 0 || e.ext > 0 { + e.carryOut(0) + } + window := e.endWindow + used := e.nendBits + for used >= ecSymBits { + e.writeByteAtEnd(window & ecSymMax) + window >>= ecSymBits + used -= ecSymBits + } + if e.err == 0 { + for i := e.offs; i < e.storage-e.endOffs; i++ { + e.buf[i] = 0 + } + if used > 0 { + if e.endOffs >= e.storage-e.offs { + e.err = -1 + } else { + e.buf[e.storage-e.endOffs-1] |= byte(window) + } + } + } +} + +// Bytes returns the encoder's output buffer. +func (e *RangeEncoder) Bytes() []byte { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L533-L535 + return e.buf +} + +// ConsumedLen reports the meaningful body length: front range bytes plus back +// raw-bit bytes (the gap between is zero-fill padding). +func (e *RangeEncoder) ConsumedLen() int { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L539-L541 + return int(e.offs + e.endOffs) +} diff --git a/pkg/call/voip/media/mlow/red.go b/pkg/call/voip/media/mlow/red.go new file mode 100644 index 00000000..73076eb6 --- /dev/null +++ b/pkg/call/voip/media/mlow/red.go @@ -0,0 +1,84 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "errors" + + "github.com/rs/zerolog" +) + +// MLow RED ("SplitRed") depacketization — the outermost wire layer of a WhatsApp +// MLow RTP audio payload (WASM func 3819). OPTIONAL: applied only when the call +// negotiated redundancy > 0; otherwise the RTP payload is a single bare MLow frame +// and this MUST NOT run (a bare frame's high-bit-set first byte would misparse). + +// MlowFrame is one frame extracted from a SplitRed payload: raw MLow frame bytes +// (TOC + body) plus RED metadata. Data is a subslice of the input payload (no copy). +type MlowFrame struct { + Data []byte + TimeCode uint8 + IsMain bool +} + +var ( + ErrPktSizeZero = errors.New("mlow red: packet size zero") + ErrHeaderTooShort = errors.New("mlow red: header too short") + ErrRedundantTooShort = errors.New("mlow red: redundant block too short") + ErrMainTooShort = errors.New("mlow red: main frame too short") +) + +// DepackSplitRed parses a SplitRed RED packet into its frames (redundant blocks in +// header order, then the main frame last). Only call when RED was negotiated. +func DepackSplitRed(p []byte, log ...zerolog.Logger) ([]MlowFrame, error) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/red.rs#L32-L95 + lg := pickLog(log) + n := len(p) + if n == 0 { + lg.Debug().Msg("red depack: empty packet") + return nil, ErrPktSizeZero + } + lg.Trace().Int("packet_bytes", n).Msg("red depack") + type redBlock struct { + code uint8 + size uint8 + } + var red []redBlock + cur := 0 + rem := n + for { + if rem == 0 { + return nil, ErrHeaderTooShort + } + b0 := p[cur] + if b0 < 0x80 { + // main marker (high bit clear) terminates the header run + if rem <= 1 { + return nil, ErrMainTooShort + } + break + } + if rem <= 2 { + return nil, ErrRedundantTooShort + } + size := p[cur+1] + if int(size)+2 >= rem { + return nil, ErrRedundantTooShort + } + red = append(red, redBlock{code: b0 & 0x7f, size: size}) + cur += 2 + rem -= int(size) + 2 + } + + mainCode := p[cur] & 0x7f + cur++ + + frames := make([]MlowFrame, 0, len(red)+1) + for _, r := range red { + frames = append(frames, MlowFrame{Data: p[cur : cur+int(r.size)], TimeCode: r.code, IsMain: false}) + cur += int(r.size) + } + mainSize := rem - 1 // total - header_size - sum(redundant sizes) + frames = append(frames, MlowFrame{Data: p[cur : cur+mainSize], TimeCode: mainCode, IsMain: true}) + lg.Trace().Int("redundant_blocks", len(red)).Int("main_bytes", mainSize).Int("total_frames", len(frames)).Msg("red depack: done") + return frames, nil +} diff --git a/pkg/call/voip/media/mlow/synth.go b/pkg/call/voip/media/mlow/synth.go new file mode 100644 index 00000000..99a0d609 --- /dev/null +++ b/pkg/call/voip/media/mlow/synth.go @@ -0,0 +1,624 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import ( + "math" + + "github.com/rs/zerolog" +) + +// Low-band synthesis: NLSF reconstruction, NLSF→LPC, gain linearization, LTP/ACB +// excitation prediction, and the per-internal-frame synthesis that turns decoded +// parameters into PCM. Validated end-to-end via the decoder module. + +const ( + SmplOrder = 16 + SmplSubfrLen = 80 // 5 ms @ 16 kHz + SmplIntfLen = 320 // 20 ms internal frame + SmplSubfrCount = 4 + SmplLtpHist = 728 +) + +const ( + smplPiF32 = float32(3.1415927410125) + smplNLSFWeightWMax = float32(999.9999) + smplNLSFWeightEps = float32(0.0009999999) + smplStabilizeMaxLoop = 1000 + smplStabilizeEps = float32(9.5367431640625e-07) +) + +const ( + gLTP = float32(0.949999988079071) + smplFracStateLen = 728 + ltpHistLen = SmplLtpHist + SmplIntfLen + 64 +) + +// smplFIR16 is the 16-tap symmetric fractional-delay interpolation FIR (WASM mem +// 0xe8780, func 3523/3507). +var smplFIR16 = [16]float32{ + -0.000006392598606907995, + 0.00011064113641623408, + -0.0009153038263320923, + 0.0048477197997272015, + -0.018698347732424736, + 0.05759090930223465, + -0.15997476875782013, + 0.617045521736145, + 0.6170454621315002, + -0.15997475385665894, + 0.05759090557694435, + -0.018698347732424736, + 0.0048477197997272015, + -0.0009153038263320923, + 0.00011064114369219169, + -0.0000063925981521606445, +} + +// --- NLSF reconstruction / synthesis tables --- + +// SmplSynthTables is the runtime synthesis table set (the smpl_synth_tables dump). +type SmplSynthTables struct { + Valtables [][][][][]float32 // [stage1][config][grid][coeff][sym] + Centroids [][][]float32 // [stage1][grid][16] + Matrices [][][][]float32 // [stage1][grid][row][col] + MinSpacing [][]float32 // [stage1][17] + Grid16W [][]float32 + Grid16Alpha []float32 + Grid16Matrices [][][]float32 // [sig][config][256] +} + +// LoadSmplSynthTables returns the runtime synthesis tables, built from the embedded +// seed ROM (lsf_seed.bin) and shared read-only. +func LoadSmplSynthTables() *SmplSynthTables { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L97-L104 + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_synth.rs#L71-L73 (seed rewire: build from lsf_seed.bin) + return loadLsfBuilt().synth +} + +// smplNLSFLaroiaWeights: inverse-gap weights w[k] = invgap[k] + invgap[k+1] (silk_NLSF_VQ_weights_laroia). +func smplNLSFLaroiaWeights(nlsf, out []float32) { + var inv [SmplOrder + 1]float32 + clamp := func(gap float32) float32 { + if gap > smplNLSFWeightEps { + return 1.0 / gap + } + return smplNLSFWeightWMax + } + inv[0] = clamp(nlsf[0]) + prev := nlsf[0] + for k := 1; k < SmplOrder; k++ { + inv[k] = clamp(nlsf[k] - prev) + prev = nlsf[k] + } + inv[SmplOrder] = clamp(smplPiF32 - nlsf[SmplOrder-1]) + for k := 0; k < SmplOrder; k++ { + out[k] = inv[k] + inv[k+1] + } +} + +// smplNLSFDecorr: out[r] = sum_c mat[c*16 + r] * vec[c] (column-major decorrelation matrix). +func smplNLSFDecorr(mat, vec, out []float32) { + var scr [SmplOrder]float32 + v0 := vec[0] + for r := 0; r < SmplOrder; r++ { + scr[r] = v0 * mat[r] + } + for c := 1; c < SmplOrder; c++ { + v := vec[c] + base := c * SmplOrder + for r := 0; r < SmplOrder; r++ { + scr[r] += mat[base+r] * v + } + } + copy(out[:SmplOrder], scr[:]) +} + +// smplStabilizeNLSF enforces minimum spacing + ordering in the margin domain (silk_NLSF_stabilize). +func smplStabilizeNLSF(nlsf, minSpacing []float32) { + const L = SmplOrder + var marg [L + 1]float32 + marg[0] = nlsf[0] - minSpacing[0] + for i := 1; i < L; i++ { + marg[i] = nlsf[i] - nlsf[i-1] - minSpacing[i] + } + marg[L] = smplPiF32 - nlsf[L-1] - minSpacing[L] + argmin := func() (float32, int) { + m := marg[0] + idx := 0 + for i := 1; i < L+1; i++ { + if marg[i] < m { + m = marg[i] + idx = i + } + } + return m, idx + } + min, sel := argmin() + loopN := 0 + for min < 0.0 { + d := float32(loopN)*smplStabilizeEps - min + if sel == 0 { + marg[0] += d + marg[1] -= d + } else if sel == L { + marg[L] += d + marg[L-1] -= d + } else { + marg[sel] += d + half := d * 0.5 + marg[sel-1] -= half + marg[sel+1] -= half + } + m, s := argmin() + min = m + sel = s + if min < 0.0 { + loopN++ + if loopN == smplStabilizeMaxLoop { + break + } + } + } + nlsf[0] = minSpacing[0] + marg[0] + run := nlsf[0] + for i := 1; i < L; i++ { + run = run + marg[i] + minSpacing[i] + nlsf[i] = run + } +} + +// SmplReconstructNLSF rebuilds the quantized NLSF from the stage indices and the +// previous frame's NLSF (the envelope the decoder synthesizes from). +func SmplReconstructNLSF(t *SmplSynthTables, stage1, config, grid int, stage2 *[16]int32, prevNLSF []float32) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L176-L234 + val := t.Valtables[stage1][config][grid] + var resid [SmplOrder]float32 + for k := 0; k < SmplOrder; k++ { + sym := stage2[k] + if sym >= 0 && int(sym) < len(val[k]) { + resid[k] = val[k][sym] + } + } + + out := make([]float32, SmplOrder) + if grid == 16 { + // grid==16: interpolate base between prevNLSF and the inverted grid16 base table. + var base [SmplOrder]float32 + baseTbl := t.Grid16W[1-stage1] + alpha := t.Grid16Alpha[stage1] + for k := 0; k < SmplOrder; k++ { + var pv float32 + if k < len(prevNLSF) { + pv = prevNLSF[k] + } + base[k] = pv + alpha*(baseTbl[k]-pv) + } + var w [SmplOrder]float32 + smplNLSFLaroiaWeights(base[:], w[:]) + for i := range w { + w[i] = float32(math.Sqrt(float64(w[i]))) + } + var decorr [SmplOrder]float32 + smplNLSFDecorr(t.Grid16Matrices[stage1][config], resid[:], decorr[:]) + for k := 0; k < SmplOrder; k++ { + out[k] = base[k] + decorr[k]/w[k] + } + smplStabilizeNLSF(out, t.MinSpacing[stage1]) + return out + } + + // matrix case (grid < 16): NLSF[r] = 2*centroid[r] + sum_c mat[c][r]*resid[c]. + cent := t.Centroids[stage1][grid] + mat := t.Matrices[stage1][grid] + for r := 0; r < SmplOrder; r++ { + acc := 2.0 * cent[r] + for c := 0; c < SmplOrder; c++ { + acc += mat[c][r] * resid[c] + } + out[r] = acc + } + smplStabilizeNLSF(out, t.MinSpacing[stage1]) + return out +} + +// SmplNLSF2A converts NLSF to the monic LPC coefficient vector A[0..16] (a[0]=1). +func SmplNLSF2A(nlsf []float32) []float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L293-L311 + // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth + // path reconstructs a tone at correlation 0.89). Correlation-bounded, not + // bit-exact — there is no isolated vector for this WASM-domain alt synth path. + order := len(nlsf) + half := order / 2 + cosv := make([]float64, order) + for i, x := range nlsf { + cosv[i] = math.Cos(float64(x)) + } + p := make([]float64, half+1) + q := make([]float64, half+1) + smplNLSFPoly(p, cosv, half, 0) + smplNLSFPoly(q, cosv, half, 1) + + a := make([]float32, order+1) + a[0] = 1.0 + for k := 0; k < half; k++ { + pt := p[k+1] + p[k] + qt := q[k+1] - q[k] + a[k+1] = float32(0.5 * (pt + qt)) + a[order-k] = float32(0.5 * (pt - qt)) + } + return a +} + +func smplNLSFPoly(out, cosv []float64, half, parity int) { + out[0] = 1.0 + out[1] = -2.0 * cosv[parity] + for k := 1; k < half; k++ { + c := -2.0 * cosv[2*k+parity] + out[k+1] = 2.0*out[k-1] + c*out[k] + for n := k; n > 1; n-- { + out[n] += out[n-2] + c*out[n-1] + } + out[1] += c + } +} + +// smplLPCSynthesis: out[n] = ex[n] - sum_{j=1..16} a[j]*out[n-j]; state holds the +// previous order outputs, carried across subframes/frames, updated in place. +func smplLPCSynthesis(ex, a, out, state []float32) { + order := SmplOrder + for n := 0; n < len(ex); n++ { + acc := float64(ex[n]) + for j := 1; j <= order; j++ { + var prev float64 + if n >= j { + prev = float64(out[n-j]) + } else { + prev = float64(state[order+n-j]) + } + acc -= float64(a[j]) * prev + } + out[n] = float32(acc) + } + if len(out) >= order { + copy(state[:order], out[len(out)-order:]) + } +} + +// SmplGainLin maps the quantized log-gain to a linear gain (fast pow2 bit-cast). +func SmplGainLin(gainQ int32) float64 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L350-L362 + // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth + // path reconstructs a tone at correlation 0.89). Correlation-bounded, not + // bit-exact — there is no isolated vector for this WASM-domain alt synth path. + y := float32(gainQ)*6.103515625e-05*0.10000000149011612*27749388.0 + 1064866816.0 + var i int32 + if y < 2147483648.0 && y > -2147483648.0 { + i = int32(y) + } else { + i = -2147483648 + } + f := math.Float32frombits(uint32(i)) - 3.1622775509276835e-09 + if f < 0.0 { + f = 0.0 + } + return float64(f) +} + +func smplFloorF32(x float32) float32 { + i := int32(x) + if float32(i) > x { + i-- + } + return float32(i) +} + +// SmplLTPFracGain maps the normalized LTP gain to the fractional gain. +func SmplLTPFracGain(normGain float64) float32 { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L482-L484 + // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth + // path reconstructs a tone at correlation 0.89). Correlation-bounded, not + // bit-exact — there is no isolated vector for this WASM-domain alt synth path. + return float32(normGain)*-0.16999998688697815 + 0.3499999940395355 +} + +// smplFir8: 8-tap symmetric FIR16 application, in-place over sig (in==out overlap; +// f32 accumulation order matches the WASM). +func smplFir8(sig []float32, inBase, outBase, cnt int32) { + for jj := int32(0); jj < cnt; jj++ { + var acc float32 + for i := int32(0); i < 8; i++ { + acc += (sig[inBase+jj+i] + sig[inBase+jj+15-i]) * smplFIR16[i] + } + sig[outBase+jj] = acc + } +} + +// smplFracLTP: fractional LTP + interpolation (func 3523). Reads sig backward from +// sigEnd, writes two regions per subframe into out (len 2*numSubfr*40); mutates sig. +func smplFracLTP(lag []float32, numSubfr int32, sig []float32, sigEnd, stateLen int32, out []float32) { + lb := sigEnd - (40*numSubfr - stateLen) + for sf := int32(0); sf < numSubfr; sf++ { + fl := smplFloorF32(lag[sf]) + intLag := int32(fl) + if float32(intLag) == lag[sf] { + for k := int32(0); k < 40; k++ { + sig[lb+k] = sig[lb+k-intLag] + } + for k := int32(0); k < 40; k++ { + out[sf*40+k] = sig[lb+k] + out[(numSubfr+sf)*40+k] = sig[lb+k-intLag-1] + sig[lb+k-intLag+1] + } + } else { + b := (numSubfr + sf) * 40 + for k := int32(0); k < 40; k++ { + out[b+k] = sig[lb-intLag-1+k] + sig[lb-intLag+1+k] + } + var l10 float32 + for j := int32(0); j < 16; j++ { + l10 += sig[lb-9-intLag+j] * smplFIR16[j] + } + smplFir8(sig, lb-intLag-8, lb, 40) + var l11 float32 + for j := int32(0); j < 16; j++ { + l11 += sig[lb+32-intLag+j] * smplFIR16[j] + } + for k := int32(0); k < 40; k++ { + out[sf*40+k] = sig[lb+k] + } + out[b] = l10 + sig[lb+1] + for k := int32(0); k < 38; k++ { + out[b+1+k] = sig[lb+k] + sig[lb+2+k] + } + out[b+39] = l11 + sig[lb+38] + } + lb += 40 + } +} + +// smplExcGainApply: per-subframe LTP gain-apply (func 3522). +func smplExcGainApply(subLen int, input []float32, st *SmplExcGainState, out []float32, gain float32) { + if gain != 0.0 { + s5 := st.S1 + s6 := (s5 + s5) + st.S0 + d := st.S0 - s5 + absD := absF32(d) + absS6 := absF32(s6) + mn := absD + gain + if absS6 < mn { + mn = absS6 + } + t := d * mn / (absD + 1e-12) + st.S1 = (s6 - t) / 3.0 + st.S0 = (2.0*t + s6) / 3.0 + } + if subLen == 0 { + return + } + s0 := st.S0 + for n := 0; n < subLen; n++ { + out[n] = s0 * input[n] + } + s1 := st.S1 + for n := 0; n < subLen; n++ { + out[n] += s1 * input[subLen+n] + } +} + +// --- low-band synthesis (WASM func 3597 core) --- + +// SmplExcGainState is the 2-tap excitation-gain smoother state. +type SmplExcGainState struct { + S0 float32 + S1 float32 +} + +// SmplPitchSynth carries the per-internal-frame pitch synthesis inputs. +type SmplPitchSynth struct { + Voiced bool + LagSubfr [4]float64 + NormGain float64 +} + +// SmplFrameSynth is the cross-internal-frame low-band synthesis state: LPC state and +// the LTP/excitation history plus the gain smoother. (The reference also carries +// Region-1 and HP postfilter state for paths gated off by SMPL_TAIL_REGION1 / +// SMPL_HP_POSTFILTER — those gated blocks are not ported here; they would need the +// postfilter module's state types.) +type SmplFrameSynth struct { + lpcState [SmplOrder]float32 + ltpHist []float32 + gst SmplExcGainState +} + +// NewSmplFrameSynth allocates a zeroed low-band synthesis state. +func NewSmplFrameSynth() *SmplFrameSynth { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L528-L538 + return &SmplFrameSynth{ltpHist: make([]float32, ltpHistLen)} +} + +// SmplLTPSubframePred runs the fractional LTP prediction for one 80-sample subframe, +// writing predOut from the history at the fractional lag (func 3523 + func 3522). +func SmplLTPSubframePred(hist []float32, histPos int32, lagF, gainFrac float32, gst *SmplExcGainState, predOut []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L487-L506 + // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth + // path reconstructs a tone at correlation 0.89). Correlation-bounded, not + // bit-exact — there is no isolated vector for this WASM-domain alt synth path. + var fracOut [2 * 2 * 40]float32 + lags := []float32{lagF, lagF} + smplFracLTP(lags, 2, hist, histPos-648, smplFracStateLen, fracOut[:]) + smplExcGainApply(SmplSubfrLen, fracOut[:], gst, predOut, gainFrac) +} + +// SynthInternalFrame synthesizes one internal (20 ms) frame, returning the PCM +// signal and the reconstructed nlsf (which becomes the next frame's prevNLSF). +func SynthInternalFrame( + t *SmplSynthTables, + st *SmplFrameSynth, + stage1, config, grid int, + stage2 *[16]int32, + prevNLSF []float32, + pulses []int32, + gainQ *[4]int32, + pitch *SmplPitchSynth, + log ...zerolog.Logger, +) (signal []float32, nlsf []float32) { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L543-L662 + lg := pickLog(log) + lg.Trace().Int("stage1", stage1).Int("grid", grid).Int("pulses_len", len(pulses)).Int("prev_nlsf_len", len(prevNLSF)).Msg("synth internal frame") + // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth + // path reconstructs a tone at correlation 0.89). Correlation-bounded, not + // bit-exact — there is no isolated vector for this WASM-domain alt synth path. + // The reference's Region-1 excitation comb and post-LPC HP postfilter are gated off + // (SMPL_TAIL_REGION1 / SMPL_HP_POSTFILTER == false) and need the postfilter + // module; those gated blocks are omitted here, matching the vector-capture config. + nlsf = SmplReconstructNLSF(t, stage1, config, grid, stage2, prevNLSF) + a := SmplNLSF2A(nlsf) + + subGain := func(sf int) float64 { + gq := int32(0) + if sf < len(gainQ) { + gq = gainQ[sf] + } + return SmplGainLin(gq) * float64(SmplSubfrLen) + } + + ex := make([]float32, SmplIntfLen) + for n := 0; n < SmplIntfLen; n++ { + ex[n] = float32(float64(pulses[n]) * subGain(n/SmplSubfrLen)) + } + hist := st.ltpHist + + if pitch.Voiced { + gainFrac := SmplLTPFracGain(pitch.NormGain) + predOut := make([]float32, SmplSubfrLen) + st.gst = SmplExcGainState{} + for sf := 0; sf < SmplSubfrCount; sf++ { + lagF := float32(pitch.LagSubfr[sf]) + intLag := int32(lagF) + if intLag <= 0 { + from := sf * SmplSubfrLen + to := (sf + 1) * SmplSubfrLen + copy(hist[SmplLtpHist+from:SmplLtpHist+to], ex[from:to]) + continue + } + exBase := sf * SmplSubfrLen + histPos := int32(SmplLtpHist + exBase) + if intLag > 0 && int(intLag) < SmplSubfrLen { + for n := int(intLag); n < SmplSubfrLen; n++ { + ex[exBase+n] += gLTP * ex[exBase+n-int(intLag)] + } + } + SmplLTPSubframePred(hist, histPos, lagF, gainFrac, &st.gst, predOut) + for n := 0; n < SmplSubfrLen; n++ { + ex[exBase+n] += predOut[n] + } + copy(hist[int(histPos):int(histPos)+SmplSubfrLen], ex[exBase:exBase+SmplSubfrLen]) + } + } else { + copy(hist[SmplLtpHist:SmplLtpHist+SmplIntfLen], ex) + } + + out := make([]float32, SmplIntfLen) + smplLPCSynthesis(ex, a, out, st.lpcState[:]) + + // roll the LTP history forward by one internal frame; clear the forward margin. + copy(hist[0:], hist[SmplIntfLen:SmplLtpHist+SmplIntfLen]) + for i := SmplLtpHist + SmplIntfLen; i < ltpHistLen; i++ { + hist[i] = 0.0 + } + return out, nlsf +} + +// (The C-float CELP synthesis — CelpDecParams / CelpDecState / SynthFrame — lives in +// celpdec.go.) + +// --- unvoiced residual-energy quantizer (smpl_quant_nrg_res.c) --- + +// NrgResQuant is the quantized residual-energy result; DbqQ14 is what the decoder +// reads as gainQ. +type NrgResQuant struct { + FrameQi int32 + ShapeQi int32 + DbqQ14 [4]int32 +} + +const ( + smplResNrgBias = float32(3.1622776e-9) + smplResNrgMinDB = float32(-85.0) + smplResNrgMaxDB = float32(0.0) + smplNrgStepDBQ14_4 = int32(16686) + smplResNrgShapeCBN4 = 98 +) + +// nrgresShapeCB4Q10 is nrgres_shape_CB_4_Q10 (98 vectors x 4 subframes), verbatim. +var nrgresShapeCB4Q10 = [smplResNrgShapeCBN4 * 4]int16{ + -2515, -2238, 2632, 2121, 790, 3973, -2872, -1891, -533, 2847, 1453, -3767, -6174, -402, 2668, 3908, + -1623, -1458, 153, 2928, -1254, 3197, -476, -1467, 1803, -1086, 270, -987, 1952, -66, -1257, -629, + 161, 19, -85, -96, 4833, 3147, -105, -7875, -1320, 1377, -1156, 1099, 3398, -2247, 1485, -2637, + -3031, 2756, 1841, -1566, -1487, 2202, -2668, 1954, 5518, -5344, 522, -696, 8400, -3123, -6235, 958, + 5152, -2444, -2811, 102, 2513, -82, 1181, -3612, -561, -197, -1074, 1832, -294, -1250, -1839, 3383, + 5126, 522, -782, -4866, -7760, -5178, -1840, 14779, -1119, 6007, -1489, -3399, -4567, -2543, 1855, 5255, + 53, -1626, 67, 1506, -12256, -7706, -1982, 21943, 3549, -969, -1096, -1484, -10824, 2981, 2204, 5639, + -229, 1106, 945, -1821, -9237, 10157, 1616, -2537, 4916, -199, -2177, -2540, 6673, 984, -3355, -4302, + -7130, -4677, 8925, 2882, 445, 2762, -348, -2859, -196, -1859, 1761, 294, 2725, -2093, -966, 334, + -3908, -308, 3675, 541, 735, 890, -2516, 891, 504, 1631, -1157, -977, -17817, 2119, 7104, 8594, + -2056, 1897, -198, 356, 292, -4544, -287, 4538, -1455, -304, 603, 1156, -18259, -12643, 15247, 15655, + 4177, 1778, -1815, -4140, 1425, 576, -294, -1707, -1301, 5132, 2838, -6669, -4727, -3148, -905, 8781, + -650, 152, -4654, 5152, 13746, 2320, -6259, -9807, -1356, 396, 3789, -2829, 2337, 1947, -29, -4256, + 6033, 820, -5730, -1123, -1795, 1091, 1080, -377, 2208, -1921, -3314, 3027, 9688, 5218, -3754, -11152, + 3814, -3941, -6183, 6310, -1017, -2391, 4393, -984, 10944, -1182, -5011, -4751, -4640, 7201, -218, -2343, + -1278, 4720, -4212, 770, 2777, 1333, -5944, 1833, -16066, 8107, 5165, 2795, 2530, -5020, 6073, -3582, + -2111, -7534, 4575, 5070, -8702, -3762, 4050, 8414, 1335, -997, -1567, 1229, 9348, 1534, -3959, -6922, + 2440, 1153, -2175, -1418, -2715, -4538, -4478, 11730, 569, -885, 2032, -1716, 3529, -91, -3218, -219, + 2157, -4121, 191, 1772, -2123, -1968, -1355, 5446, 1475, -354, 3651, -4772, 1654, -3521, 2726, -859, + 2393, 6820, -2958, -6255, -3861, 1365, 1177, 1319, 7614, -1638, -2789, -3187, -3628, -2635, 6902, -639, + 1925, 2295, -1451, -2769, -3683, 4517, -981, 147, -1260, -529, 2339, -550, 3013, 639, -1050, -2602, + 3651, 1959, -3218, -2391, 6267, 3124, -2926, -6464, -8180, 3900, 4191, 89, -3372, -611, 1042, 2941, + -2510, 856, -925, 2579, -11667, -8436, 10605, 9498, 6427, -2733, 1887, -5581, 1581, -1722, -328, 469, + 2011, 1989, -3606, -394, -1014, 2197, -1200, 17, 1544, -2555, 765, 247, 1188, -183, 1966, -2972, + -6057, 3480, -2284, 4860, -25659, 8466, 8891, 8303, +} + +// QuantNrgRes4 quantizes the 4-subframe residual-energy vector (smpl_quant_nrg_res, num_subfr==4). +func QuantNrgRes4(nrgres *[4]float32) NrgResQuant { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_nrgres.rs#L61-L101 + // Exercised by TestEncodeRoundTripsATone (the encoder unvoiced candidate quantizes + // the per-subframe residual energy through this). Correlation-bounded e2e. + var nrgresDB [4]float32 + var frameDB float32 + for i := 0; i < 4; i++ { + v := 10.0 * float32(math.Log10(float64(nrgres[i]+smplResNrgBias))) + if v > smplResNrgMaxDB { + v = smplResNrgMaxDB + } + nrgresDB[i] = v + frameDB += v + } + frameDB /= 4.0 + scQ14 := float32(1.0) / float32(int32(1)<<14) + frameQi := int32(math.Round(float64((frameDB - smplResNrgMinDB) / (scQ14 * float32(smplNrgStepDBQ14_4))))) + frameDbqQ14 := frameQi * smplNrgStepDBQ14_4 + frameDbqQ14 += int32(smplResNrgMinDB) * (1 << 14) + for i := 0; i < 4; i++ { + nrgresDB[i] -= float32(frameDbqQ14) * scQ14 + } + scQ10 := float32(1.0) / float32(int32(1)<<10) + bestRD := float32(1e30) + qi := 0 + for n := 0; n < smplResNrgShapeCBN4; n++ { + var rd float32 + for i := 0; i < 4; i++ { + d := nrgresDB[i] - float32(nrgresShapeCB4Q10[n*4+i])*scQ10 + rd += d * d + } + if rd < bestRD { + qi = n + bestRD = rd + } + } + var dbqQ14 [4]int32 + for i := 0; i < 4; i++ { + dbqQ14[i] = frameDbqQ14 + int32(nrgresShapeCB4Q10[qi*4+i])*16 + } + return NrgResQuant{FrameQi: frameQi, ShapeQi: int32(qi), DbqQ14: dbqQ14} +} diff --git a/pkg/call/voip/media/mlow/toc.go b/pkg/call/voip/media/mlow/toc.go new file mode 100644 index 00000000..1ce6c7bd --- /dev/null +++ b/pkg/call/voip/media/mlow/toc.go @@ -0,0 +1,76 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "github.com/rs/zerolog" + +// SmplTOC is the decoded first byte of an inbound MLow frame: how to interpret +// the rest of the frame, or that it is a standard Opus packet to route elsewhere. +type SmplTOC struct { + StdOpus bool + SID bool + VAD bool + SampleRate int + FrameMs int + Voiced bool + Active bool + Flag2 bool + Flag0 bool +} + +// standardOpusFrameMs returns the frame duration (ms) of a standard Opus packet +// from the config field b>>3 (RFC 6716 Table 2). 2.5 ms is rounded up to 3. +func standardOpusFrameMs(b byte) int { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/toc.rs#L25-L39 + config := b >> 3 + switch { + case config < 12: // SILK NB/MB/WB + return []int{10, 20, 40, 60}[config&3] + case config < 16: // Hybrid + return []int{10, 20}[(config-12)&1] + default: + switch config & 3 { + case 0: + return 3 // 2.5 ms rounded up + case 1: + return 5 + case 2: + return 10 + default: + return 20 + } + } +} + +// ParseSmplTOC decodes the TOC byte at the head of an inbound MLow frame. +func ParseSmplTOC(b byte, log ...zerolog.Logger) SmplTOC { + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/toc.rs#L43-L87 + lg := pickLog(log) + if b&0xC0 == 0xC0 { + lg.Trace().Uint8("toc_byte", b).Bool("std_opus", true).Msg("parse toc: standard-Opus packet") + return SmplTOC{ + StdOpus: true, + SampleRate: 16000, + FrameMs: standardOpusFrameMs(b), + } + } + bit1 := (b>>1)&1 != 0 + vad := (b>>6)&1 != 0 + sampleRate := 16000 + if b&0x20 != 0 { + sampleRate = 32000 + } + toc := SmplTOC{ + SID: b>>7 != 0, + VAD: vad, + SampleRate: sampleRate, + FrameMs: []int{10, 20, 60, 120}[(b>>3)&3], + Voiced: vad && bit1, + Active: vad || bit1, + Flag2: (b>>2)&1 != 0, + Flag0: b&1 != 0, + } + lg.Trace().Uint8("toc_byte", b).Bool("sid", toc.SID).Bool("vad", toc.VAD). + Bool("voiced", toc.Voiced).Bool("active", toc.Active).Int("frame_ms", toc.FrameMs). + Int("sample_rate", toc.SampleRate).Msg("parse toc") + return toc +} diff --git a/pkg/call/voip/media/mlow/vad.go b/pkg/call/voip/media/mlow/vad.go new file mode 100644 index 00000000..0e3ec581 --- /dev/null +++ b/pkg/call/voip/media/mlow/vad.go @@ -0,0 +1,393 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS. +package mlow + +import "math/bits" + +// SILK VAD (smpl_vad.c): per-internal-frame speech-activity probability and the +// coded_as_active_voice flag. Faithful fixed-point port of smpl_VAD_GetSA_Q8_c + +// GetNoiseLevels + the 2-band allpass filterbank + the per-packet hangover. Runs on +// raw int16 input PCM at 16 kHz, 320 samples per internal frame. +// +// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_vad.rs#L1-L538 + +// ---- SILK fixed-point primitives ---- + +const ( + silkInt32Max = int32(0x7FFFFFFF) + // silkInt16Max is defined in lpc.go (32767); reused here. + silkInt16Min = int32(-0x8000) + silkUint8Max = int32(0xFF) +) + +func sat16(a int32) int32 { + if a < silkInt16Min { + return silkInt16Min + } + if a > silkInt16Max { + return silkInt16Max + } + return a +} + +func smulwb(a, b int32) int32 { return int32((int64(a) * int64(int16(b))) >> 16) } +func smlawb(a, b, c int32) int32 { + return int32(int64(a) + ((int64(b) * int64(int16(c))) >> 16)) +} +func smulww(a, b int32) int32 { return int32((int64(a) * int64(b)) >> 16) } +func smulbb(a, b int32) int32 { return int32(int16(a)) * int32(int16(b)) } +func smlabb(a, b, c int32) int32 { + return a + int32(int16(b))*int32(int16(c)) +} + +func addPosSat32(a, b int32) int32 { + if (uint32(a)+uint32(b))&0x80000000 != 0 { + return silkInt32Max + } + return int32(uint32(a) + uint32(b)) +} + +func div32(a, b int32) int32 { return a / b } + +func clz32(x int32) int32 { return int32(bits.LeadingZeros32(uint32(x))) } + +// ror32: rotate right by rot (RotateLeft32 with -k rotates right). +func ror32(a32, rot int32) int32 { + return int32(bits.RotateLeft32(uint32(a32), -int(rot&31))) +} + +func clzFrac(inp int32) (int32, int32) { + lz := clz32(inp) + fracQ7 := ror32(inp, 24-lz) & 0x7f + return lz, fracQ7 +} + +// lin2log: approximation of 128 * log2(). +func lin2log(inLin int32) int32 { + lz, fracQ7 := clzFrac(inLin) + return smlawb(fracQ7, fracQ7*(128-fracQ7), 179) + ((31 - lz) << 7) +} + +func sqrtApprox(x int32) int32 { + if x <= 0 { + return 0 + } + lz, fracQ7 := clzFrac(x) + var y int32 + if lz&1 != 0 { + y = 32768 + } else { + y = 46214 + } + y >>= lz >> 1 + return smlawb(y, y, smulbb(213, fracQ7)) +} + +// sigmQ15: piecewise-linear sigmoid approximation. +func sigmQ15(inQ5 int32) int32 { + slope := [6]int32{237, 153, 73, 30, 12, 7} + pos := [6]int32{16384, 23955, 28861, 31213, 32178, 32548} + neg := [6]int32{16384, 8812, 3906, 1554, 589, 219} + if inQ5 < 0 { + inQ5 = -inQ5 + if inQ5 >= 6*32 { + return 0 + } + ind := inQ5 >> 5 + return neg[ind] - smulbb(slope[ind], inQ5&0x1F) + } + if inQ5 >= 6*32 { + return 32767 + } + ind := inQ5 >> 5 + return pos[ind] + smulbb(slope[ind], inQ5&0x1F) +} + +// rshiftRound: silk_RSHIFT_ROUND. +func rshiftRound(a, shift int32) int32 { + if shift == 1 { + return (a >> 1) + (a & 1) + } + return ((a >> (shift - 1)) + 1) >> 1 +} + +// ---- VAD constants ---- + +const ( + vadNBands = 4 + vadInternalSubframesLog2 = 2 + vadInternalSubframes = 1 << vadInternalSubframesLog2 + vadNoiseLevelSmoothCoefQ16 = 1024 + vadNoiseLevelsBias = 50 + vadNegativeOffsetQ5 = 128 + vadSnrFactorQ16 = 45000 + aFB120 = 3894 << 1 + aFB121 = -29322 + speechActivityDtxThresQ8 = 12 // SILK_FIX_CONST(0.05, 8) +) + +var tiltWeights = [vadNBands]int32{30000, 6000, -12000, -12000} + +// SmplVadState is the persistent SILK VAD state, carried across packets. +type SmplVadState struct { + anaState [2]int32 + anaState1 [2]int32 + anaState2 [2]int32 + xnrgSubfr [vadNBands]int32 + nl [vadNBands]int32 + invNl [vadNBands]int32 + noiseLevelBias [vadNBands]int32 + counter int32 + hpState int32 + noiseLvlUpdateSpeed int32 + nonBinariness int32 + highpassSharpness int32 + remainingDtxHangover int32 + hangoverMs int32 +} + +type vadType int + +const ( + vadActive vadType = iota + vadInactive + vadHangover +) + +// VadPacketResult is the VAD output for one 60 ms packet. +type VadPacketResult struct { + VadResults [3]float32 + CodedAsActiveVoice bool +} + +// NewSmplVadState initializes the VAD (smpl_VAD_Init). +func NewSmplVadState() *SmplVadState { + s := &SmplVadState{counter: 15, remainingDtxHangover: 60, hangoverMs: 60} + for b := 0; b < vadNBands; b++ { + bias := vadNoiseLevelsBias / (int32(b) + 1) + if bias < 1 { + bias = 1 + } + s.noiseLevelBias[b] = bias + s.nl[b] = 100 * bias + s.invNl[b] = silkInt32Max / s.nl[b] + } + return s +} + +// filtHP: first-order ARMA HP filter with zero at DC, in place over len samples. +func (s *SmplVadState) filtHP(x []int32, bQ16, aNegQ16 int32, length int) { + for i := 0; i < length; i++ { + inval := smulwb(bQ16, x[i]) + outval := sat16(s.hpState - inval) + s.hpState = smlawb(inval, aNegQ16, outval) + x[i] = outval + } +} + +// anaFiltBank1: 2-band split via first-order allpass filters. Writes low band to +// outL[0..n/2] and high band to outH[0..n/2]; s is the carried 2-element state. +func anaFiltBank1(inp []int32, s *[2]int32, outL, outH []int32, n int) { + n2 := n >> 1 + for k := 0; k < n2; k++ { + in32 := inp[2*k] << 10 + y := in32 - s[0] + x := smlawb(y, y, aFB121) + out1 := s[0] + x + s[0] = in32 + x + + in32 = inp[2*k+1] << 10 + y = in32 - s[1] + x = smulwb(y, aFB120) + out2 := s[1] + x + s[1] = in32 + x + + outL[k] = sat16(rshiftRound(out2+out1, 11)) + outH[k] = sat16(rshiftRound(out2-out1, 11)) + } +} + +// anaFiltBank1Inplace: in-place 2-band split — reads x[0..n], writes low band to +// x[0..n/2] and high band to x[hiOff..hiOff+n/2]. +func anaFiltBank1Inplace(x []int32, hiOff int, s *[2]int32, n int) { + n2 := n >> 1 + for k := 0; k < n2; k++ { + in32 := x[2*k] << 10 + y := in32 - s[0] + xx := smlawb(y, y, aFB121) + out1 := s[0] + xx + s[0] = in32 + xx + + in32 = x[2*k+1] << 10 + y = in32 - s[1] + xx = smulwb(y, aFB120) + out2 := s[1] + xx + s[1] = in32 + xx + + x[hiOff+k] = sat16(rshiftRound(out2-out1, 11)) + x[k] = sat16(rshiftRound(out2+out1, 11)) + } +} + +// getNoiseLevels: smpl_VAD_GetNoiseLevels. +func (s *SmplVadState) getNoiseLevels(pX *[vadNBands]int32) { + var minCoef int32 + if s.counter < 1000 { + minCoef = div32(silkInt16Max, (s.counter>>4)+1) + s.counter++ + } + for b := 0; b < vadNBands; b++ { + nl := s.nl[b] + nrg := addPosSat32(pX[b], s.noiseLevelBias[b]) + invNrg := div32(silkInt32Max, nrg) + var coef int32 + switch { + case nrg > (nl << 3): + coef = vadNoiseLevelSmoothCoefQ16 >> 3 + case nrg < nl: + coef = vadNoiseLevelSmoothCoefQ16 + default: + coef = smulwb(smulww(invNrg, nl), vadNoiseLevelSmoothCoefQ16<<1) + } + coef = (coef * (100 + s.noiseLvlUpdateSpeed)) / 100 + if coef < minCoef { + coef = minCoef + } + s.invNl[b] = smlawb(s.invNl[b], invNrg-s.invNl[b], coef) + v := div32(silkInt32Max, s.invNl[b]) + if v > 0x00FFFFFF { + v = 0x00FFFFFF + } + s.nl[b] = v + } +} + +// getSAQ8: smpl_VAD_GetSA_Q8_c — speech_activity_Q8 for one framelen-sample frame. +func (s *SmplVadState) getSAQ8(pIn []int32, framelen int) int32 { + decFl1 := framelen >> 1 + decFl2 := framelen >> 2 + decFl3 := framelen >> 3 + + var xOffset [vadNBands]int + xOffset[0] = 0 + xOffset[1] = decFl3 + decFl2 + xOffset[2] = xOffset[1] + decFl3 + xOffset[3] = xOffset[2] + decFl2 + xTotal := xOffset[3] + decFl1 + x := make([]int32, xTotal) + + anaFiltBank1(pIn, &s.anaState, x[:xOffset[3]], x[xOffset[3]:], framelen) + anaFiltBank1Inplace(x, xOffset[2], &s.anaState1, decFl1) + anaFiltBank1Inplace(x, xOffset[1], &s.anaState2, decFl2) + + // HP filter on the lowest band, -3 dB @ 66 Hz. + aNegQ16 := int32(53084) + aNegQ16 = (aNegQ16 * (100 - s.highpassSharpness)) / 100 + bQ16 := (65536 + aNegQ16) / 2 + s.filtHP(x[:decFl3], bQ16, aNegQ16, decFl3) + + // Energy in each band. + var xnrg [vadNBands]int32 + for b := 0; b < vadNBands; b++ { + shift := vadNBands - b + if shift > vadNBands-1 { + shift = vadNBands - 1 + } + dec := framelen >> shift + decSubfrLen := dec >> vadInternalSubframesLog2 + decSubfrOffset := 0 + xnrg[b] = s.xnrgSubfr[b] + var sumSquared int32 + for sub := 0; sub < vadInternalSubframes; sub++ { + sumSquared = 0 + for i := 0; i < decSubfrLen; i++ { + xTmp := x[xOffset[b]+i+decSubfrOffset] >> 3 + sumSquared = smlabb(sumSquared, xTmp, xTmp) + } + if sub < vadInternalSubframes-1 { + xnrg[b] = addPosSat32(xnrg[b], sumSquared) + } else { + xnrg[b] = addPosSat32(xnrg[b], sumSquared>>1) + } + decSubfrOffset += decSubfrLen + } + s.xnrgSubfr[b] = sumSquared + } + + s.getNoiseLevels(&xnrg) + + // Signal-plus-noise to noise ratio. + var sumSquared int32 + var inputTilt int32 + for b := 0; b < vadNBands; b++ { + speechNrg := xnrg[b] - s.nl[b] + if speechNrg > 0 { + var ratioQ8 int32 + if (xnrg[b] & -0x00800000) == 0 { // 0xFF800000 as int32 + ratioQ8 = div32(xnrg[b]<<8, s.nl[b]+1) + } else { + ratioQ8 = div32(xnrg[b], (s.nl[b]>>8)+1) + } + snrQ7 := lin2log(ratioQ8) - 8*128 + sumSquared = smlabb(sumSquared, snrQ7, snrQ7) + if speechNrg < (1 << 20) { + snrQ7 = smulwb(sqrtApprox(speechNrg)<<6, snrQ7) + } + inputTilt = smlawb(inputTilt, tiltWeights[b], snrQ7) + } + } + sumSquared = div32(sumSquared, vadNBands) + pSnrDbQ7 := int32(int16(3 * sqrtApprox(sumSquared))) + + vadSnrFactorQ16 := (int32(vadSnrFactorQ16) * (150 - s.nonBinariness)) / 150 + saQ15 := sigmQ15(smulwb(vadSnrFactorQ16, pSnrDbQ7) - vadNegativeOffsetQ5) + + _ = inputTilt + r := saQ15 >> 7 + if r > silkUint8Max { + r = silkUint8Max + } + return r +} + +// ProcessPacket processes one 60 ms packet (3 internal frames of framelen int16 samples). +func (s *SmplVadState) ProcessPacket(pcmI16 []int16, framelen int) VadPacketResult { + const framesPerPacket = 3 + const packetMs = 60 + var vadResults [3]float32 + // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/543302e762ef36913b3e2fdf7f84510c43265272/wacore/src/voip/mlow/smpl_vad.rs#L406-L412 (upstream short-packet guard) + // Reject a short capture buffer up front so the fixed-stride frame loop can't + // index out of range (mirrors the C VAD's short-packet guard). + if len(pcmI16) < framesPerPacket*framelen { + return VadPacketResult{} + } + var vt [3]vadType + for i := 0; i < framesPerPacket; i++ { + t := i * framelen + frame := make([]int32, framelen) + for j := 0; j < framelen; j++ { + frame[j] = int32(pcmI16[t+j]) + } + saQ8 := s.getSAQ8(frame, framelen) + vadResults[i] = float32(saQ8) / 256.0 + if saQ8 > speechActivityDtxThresQ8 { + vt[i] = vadActive + } else { + vt[i] = vadInactive + } + } + + codedAsActiveVoice := false + for i := range vt { + if vt[i] == vadActive { + s.remainingDtxHangover = s.hangoverMs + } else if s.remainingDtxHangover > 0 { + vt[i] = vadHangover + s.remainingDtxHangover -= packetMs / framesPerPacket + } + if vt[i] != vadInactive { + codedAsActiveVoice = true + } + } + + return VadPacketResult{VadResults: vadResults, CodedAsActiveVoice: codedAsActiveVoice} +} diff --git a/pkg/call/voip/media/mlow_codec.go b/pkg/call/voip/media/mlow_codec.go new file mode 100644 index 00000000..2a65a4c3 --- /dev/null +++ b/pkg/call/voip/media/mlow_codec.go @@ -0,0 +1,74 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import ( + "fmt" + "sync" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/media/mlow" +) + +type mlowCodec struct { + mu sync.Mutex + enc *mlow.MlowEncoder + dec *mlow.MlowDecoder + closed bool +} + +func NewMLowCodec(opts CodecOptions) (Codec, error) { + _ = opts + return &mlowCodec{enc: mlow.NewMlowEncoder(), dec: mlow.NewMlowDecoder()}, nil +} + +func (c *mlowCodec) Encode(pcm []float32) ([]byte, error) { + if len(pcm) == 0 { + return nil, nil + } + if len(pcm) != MLowFrameSize { + return nil, fmt.Errorf("%w: got %d samples, want %d", ErrInvalidPCMFrame, len(pcm), MLowFrameSize) + } + c.mu.Lock() + defer c.mu.Unlock() + if c.closed || c.enc == nil { + return nil, ErrCodecClosed + } + input := append([]float32(nil), pcm...) + defer zeroFloat32(input) + encoded, err := c.enc.Encode(input) + if err != nil { + return nil, fmt.Errorf("encode MLow frame: %w", err) + } + return append([]byte(nil), encoded...), nil +} + +func (c *mlowCodec) Decode(frame []byte) ([]float32, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed || c.dec == nil { + return nil, ErrCodecClosed + } + input := append([]byte(nil), frame...) + defer zeroBytes(input) + decoded := c.dec.Decode(input) + return NormalizeFrame(decoded, MLowFrameSize), nil +} + +func (c *mlowCodec) FrameSize() int { return MLowFrameSize } +func (c *mlowCodec) SampleRate() int { return MLowSampleRate } + +func (c *mlowCodec) Close() { + if c == nil { + return + } + c.mu.Lock() + c.closed = true + c.enc = nil + c.dec = nil + c.mu.Unlock() +} + +func zeroFloat32(values []float32) { + for index := range values { + values[index] = 0 + } +} diff --git a/pkg/call/voip/media/mlow_codec_test.go b/pkg/call/voip/media/mlow_codec_test.go new file mode 100644 index 00000000..3db45cb5 --- /dev/null +++ b/pkg/call/voip/media/mlow_codec_test.go @@ -0,0 +1,83 @@ +package media + +import ( + "errors" + "math" + "sync" + "testing" +) + +func TestMLowCodecAdapterRoundtrip(t *testing.T) { + codec, err := NewMLowCodec(DefaultCodecOptions) + if err != nil { + t.Fatal(err) + } + defer codec.Close() + frame := make([]float32, MLowFrameSize) + for i := range frame { + frame[i] = 0.25 * float32(math.Sin(2*math.Pi*440*float64(i)/MLowSampleRate)) + } + encoded, err := codec.Encode(frame) + if err != nil { + t.Fatal(err) + } + if len(encoded) == 0 { + t.Fatal("encoded frame is empty") + } + decoded, err := codec.Decode(encoded) + if err != nil { + t.Fatal(err) + } + if len(decoded) != MLowFrameSize { + t.Fatalf("decoded %d samples, want %d", len(decoded), MLowFrameSize) + } +} + +func TestMLowCodecPLCAndValidation(t *testing.T) { + codec, err := NewMLowCodec(DefaultCodecOptions) + if err != nil { + t.Fatal(err) + } + plc, err := codec.Decode(nil) + if err != nil { + t.Fatal(err) + } + if len(plc) != MLowFrameSize { + t.Fatalf("PLC returned %d samples", len(plc)) + } + if _, err = codec.Encode(make([]float32, 12)); !errors.Is(err, ErrInvalidPCMFrame) { + t.Fatalf("expected ErrInvalidPCMFrame, got %v", err) + } + codec.Close() + if _, err = codec.Decode(nil); !errors.Is(err, ErrCodecClosed) { + t.Fatalf("expected ErrCodecClosed, got %v", err) + } +} + +func TestMLowCodecSerializesConcurrentUse(t *testing.T) { + codec, err := NewMLowCodec(DefaultCodecOptions) + if err != nil { + t.Fatal(err) + } + defer codec.Close() + frame := make([]float32, MLowFrameSize) + var wg sync.WaitGroup + for worker := 0; worker < 4; worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for attempt := 0; attempt < 4; attempt++ { + encoded, encodeErr := codec.Encode(frame) + if encodeErr != nil { + t.Errorf("encode: %v", encodeErr) + return + } + if _, decodeErr := codec.Decode(encoded); decodeErr != nil { + t.Errorf("decode: %v", decodeErr) + return + } + } + }() + } + wg.Wait() +} diff --git a/tools/port_mlow_codec.py b/tools/port_mlow_codec.py deleted file mode 100644 index e0f20419..00000000 --- a/tools/port_mlow_codec.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import shutil -import subprocess -import tempfile -from pathlib import Path - -UPSTREAM_URL = "https://github.com/JotaDev66/WaCalls.git" -UPSTREAM_COMMIT = "edeb31f0427aba896639db503153b777a405eccf" -ROOT = Path(__file__).resolve().parents[1] -TARGET = ROOT / "pkg/call/voip/media/mlow" -LICENSE_HEADER = "// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.\n" - - -def run(*args: str, cwd: Path | None = None) -> None: - subprocess.run(args, cwd=cwd, check=True) - - -def add_license_header(source: str) -> str: - if "JotaDev66/WaCalls" in source[:300]: - return source - return LICENSE_HEADER + source - - -with tempfile.TemporaryDirectory(prefix="wacalls-mlow-") as tmp: - checkout = Path(tmp) / "WaCalls" - run("git", "clone", "--filter=blob:none", "--no-checkout", UPSTREAM_URL, str(checkout)) - run("git", "-C", str(checkout), "fetch", "--depth", "1", "origin", UPSTREAM_COMMIT) - run("git", "-C", str(checkout), "checkout", "--detach", UPSTREAM_COMMIT) - - source_dir = checkout / "internal/voip/media/mlow" - sources = sorted(path for path in source_dir.glob("*.go") if not path.name.endswith("_test.go")) - assets = sorted(source_dir.glob("*.bin")) - if len(sources) < 20: - raise RuntimeError(f"expected the complete MLow implementation, found only {len(sources)} files") - if len(assets) < 3: - raise RuntimeError(f"expected embedded MLow tables, found only {len(assets)} binary assets") - - shutil.rmtree(TARGET, ignore_errors=True) - TARGET.mkdir(parents=True, exist_ok=True) - for source_path in sources: - source = source_path.read_text(encoding="utf-8") - if "package mlow" not in source: - raise RuntimeError(f"unexpected package in {source_path}") - (TARGET / source_path.name).write_text(add_license_header(source), encoding="utf-8") - for asset_path in assets: - shutil.copy2(asset_path, TARGET / asset_path.name) - -codec = '''// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. -package media - -import "errors" - -const ( - MLowSampleRate = 16000 - MLowFrameSize = 960 -) - -var ( - ErrCodecClosed = errors.New("audio codec is closed") - ErrInvalidPCMFrame = errors.New("invalid PCM frame size") -) - -type Codec interface { - Encode(pcm []float32) ([]byte, error) - Decode(frame []byte) ([]float32, error) - FrameSize() int - SampleRate() int - Close() -} - -type CodecOptions struct { - Bitrate int - Complexity int - FEC bool -} - -var DefaultCodecOptions = CodecOptions{Bitrate: 6000, Complexity: 5, FEC: false} - -func NormalizeFrame(pcm []float32, samples int) []float32 { - if samples <= 0 { - return nil - } - if len(pcm) == samples { - return append([]float32(nil), pcm...) - } - normalized := make([]float32, samples) - copy(normalized, pcm) - return normalized -} -''' -(ROOT / "pkg/call/voip/media/codec.go").write_text(codec, encoding="utf-8") - -adapter = '''// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. -package media - -import ( - "fmt" - "sync" - - "github.com/evolution-foundation/evolution-go/pkg/call/voip/media/mlow" -) - -type mlowCodec struct { - mu sync.Mutex - enc *mlow.MlowEncoder - dec *mlow.MlowDecoder - closed bool -} - -func NewMLowCodec(opts CodecOptions) (Codec, error) { - _ = opts - return &mlowCodec{enc: mlow.NewMlowEncoder(), dec: mlow.NewMlowDecoder()}, nil -} - -func (c *mlowCodec) Encode(pcm []float32) ([]byte, error) { - if len(pcm) == 0 { - return nil, nil - } - if len(pcm) != MLowFrameSize { - return nil, fmt.Errorf("%w: got %d samples, want %d", ErrInvalidPCMFrame, len(pcm), MLowFrameSize) - } - c.mu.Lock() - defer c.mu.Unlock() - if c.closed || c.enc == nil { - return nil, ErrCodecClosed - } - input := append([]float32(nil), pcm...) - defer zeroFloat32(input) - encoded, err := c.enc.Encode(input) - if err != nil { - return nil, fmt.Errorf("encode MLow frame: %w", err) - } - return append([]byte(nil), encoded...), nil -} - -func (c *mlowCodec) Decode(frame []byte) ([]float32, error) { - c.mu.Lock() - defer c.mu.Unlock() - if c.closed || c.dec == nil { - return nil, ErrCodecClosed - } - input := append([]byte(nil), frame...) - defer zeroBytes(input) - decoded := c.dec.Decode(input) - return NormalizeFrame(decoded, MLowFrameSize), nil -} - -func (c *mlowCodec) FrameSize() int { return MLowFrameSize } -func (c *mlowCodec) SampleRate() int { return MLowSampleRate } - -func (c *mlowCodec) Close() { - if c == nil { - return - } - c.mu.Lock() - c.closed = true - c.enc = nil - c.dec = nil - c.mu.Unlock() -} - -func zeroFloat32(values []float32) { - for index := range values { - values[index] = 0 - } -} -''' -(ROOT / "pkg/call/voip/media/mlow_codec.go").write_text(adapter, encoding="utf-8") - -test = '''package media - -import ( - "errors" - "math" - "sync" - "testing" -) - -func TestMLowCodecAdapterRoundtrip(t *testing.T) { - codec, err := NewMLowCodec(DefaultCodecOptions) - if err != nil { - t.Fatal(err) - } - defer codec.Close() - frame := make([]float32, MLowFrameSize) - for i := range frame { - frame[i] = 0.25 * float32(math.Sin(2*math.Pi*440*float64(i)/MLowSampleRate)) - } - encoded, err := codec.Encode(frame) - if err != nil { - t.Fatal(err) - } - if len(encoded) == 0 { - t.Fatal("encoded frame is empty") - } - decoded, err := codec.Decode(encoded) - if err != nil { - t.Fatal(err) - } - if len(decoded) != MLowFrameSize { - t.Fatalf("decoded %d samples, want %d", len(decoded), MLowFrameSize) - } -} - -func TestMLowCodecPLCAndValidation(t *testing.T) { - codec, err := NewMLowCodec(DefaultCodecOptions) - if err != nil { - t.Fatal(err) - } - plc, err := codec.Decode(nil) - if err != nil { - t.Fatal(err) - } - if len(plc) != MLowFrameSize { - t.Fatalf("PLC returned %d samples", len(plc)) - } - if _, err = codec.Encode(make([]float32, 12)); !errors.Is(err, ErrInvalidPCMFrame) { - t.Fatalf("expected ErrInvalidPCMFrame, got %v", err) - } - codec.Close() - if _, err = codec.Decode(nil); !errors.Is(err, ErrCodecClosed) { - t.Fatalf("expected ErrCodecClosed, got %v", err) - } -} - -func TestMLowCodecSerializesConcurrentUse(t *testing.T) { - codec, err := NewMLowCodec(DefaultCodecOptions) - if err != nil { - t.Fatal(err) - } - defer codec.Close() - frame := make([]float32, MLowFrameSize) - var wg sync.WaitGroup - for worker := 0; worker < 4; worker++ { - wg.Add(1) - go func() { - defer wg.Done() - for attempt := 0; attempt < 4; attempt++ { - encoded, encodeErr := codec.Encode(frame) - if encodeErr != nil { - t.Errorf("encode: %v", encodeErr) - return - } - if _, decodeErr := codec.Decode(encoded); decodeErr != nil { - t.Errorf("decode: %v", decodeErr) - return - } - } - }() - } - wg.Wait() -} -''' -(ROOT / "pkg/call/voip/media/mlow_codec_test.go").write_text(test, encoding="utf-8") - -(ROOT / "tools/port_mlow_codec.py").unlink() -(ROOT / ".github/workflows/port-mlow-codec.yml").unlink() From 844f5a16b24b61f4fc386a89fad292d8da0edf95 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:45:05 -0300 Subject: [PATCH 109/266] feat(call): add PCM codec pipeline --- pkg/call/voip/media/audio_pipeline.go | 381 ++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 pkg/call/voip/media/audio_pipeline.go diff --git a/pkg/call/voip/media/audio_pipeline.go b/pkg/call/voip/media/audio_pipeline.go new file mode 100644 index 00000000..718562c5 --- /dev/null +++ b/pkg/call/voip/media/audio_pipeline.go @@ -0,0 +1,381 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import ( + "errors" + "fmt" + "math" + "sync" + "time" +) + +var ( + ErrAudioSessionNotReady = errors.New("audio codec session is not ready") + ErrAudioSenderUnavailable = errors.New("encoded audio sender is unavailable") +) + +type CodecFactory func(options CodecOptions) (Codec, error) + +type EncodedAudioSender func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error + +type PCMCallback func(instanceID, callID string, pcm []float32) + +type AudioRegistryOptions struct { + CodecFactory CodecFactory + CodecOptions CodecOptions + SilenceTick time.Duration + SilenceAfter time.Duration + DisableSilence bool +} + +func DefaultAudioRegistryOptions() AudioRegistryOptions { + return AudioRegistryOptions{ + CodecFactory: NewMLowCodec, + CodecOptions: DefaultCodecOptions, + SilenceTick: 60 * time.Millisecond, + SilenceAfter: 120 * time.Millisecond, + } +} + +type audioSession struct { + mu sync.Mutex + + instanceID string + callID string + codec Codec + sender EncodedAudioSender + + encodeBuffer []float32 + encodePos int + marker bool + lastCapture time.Time + closed bool + + silenceTick time.Duration + silenceAfter time.Duration + stopCh chan struct{} + doneCh chan struct{} + stopOnce sync.Once +} + +func newAudioSession(instanceID, callID string, codec Codec, sender EncodedAudioSender, options AudioRegistryOptions) *audioSession { + session := &audioSession{ + instanceID: instanceID, + callID: callID, + codec: codec, + sender: sender, + encodeBuffer: make([]float32, codec.FrameSize()), + marker: true, + lastCapture: time.Now(), + silenceTick: options.SilenceTick, + silenceAfter: options.SilenceAfter, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } + if options.DisableSilence || options.SilenceTick <= 0 || options.SilenceAfter <= 0 { + close(session.doneCh) + } else { + go session.silenceLoop() + } + return session +} + +func (s *audioSession) feedPCM(pcm []float32) error { + if s == nil { + return ErrAudioSessionNotReady + } + if len(pcm) == 0 { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.closed || s.codec == nil { + return ErrAudioSessionNotReady + } + if s.sender == nil { + return ErrAudioSenderUnavailable + } + + s.lastCapture = time.Now() + offset := 0 + for offset < len(pcm) { + remaining := s.codec.FrameSize() - s.encodePos + count := min(remaining, len(pcm)-offset) + for index := 0; index < count; index++ { + s.encodeBuffer[s.encodePos+index] = sanitizePCMSample(pcm[offset+index]) + } + s.encodePos += count + offset += count + if s.encodePos != s.codec.FrameSize() { + continue + } + if err := s.encodeAndSendLocked(s.encodeBuffer); err != nil { + return err + } + zeroFloat32(s.encodeBuffer) + s.encodePos = 0 + } + return nil +} + +func (s *audioSession) handleRTP(packet *RTPPacket) ([]float32, error) { + if s == nil || packet == nil || packet.Header == nil { + return nil, ErrAudioSessionNotReady + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed || s.codec == nil { + return nil, ErrAudioSessionNotReady + } + decoded, err := s.codec.Decode(packet.Payload) + if err != nil { + return nil, fmt.Errorf("decode MLow payload: %w", err) + } + return NormalizeFrame(decoded, s.codec.FrameSize()), nil +} + +func (s *audioSession) encodeAndSendLocked(frame []float32) error { + encoded, err := s.codec.Encode(frame) + if err != nil { + return fmt.Errorf("encode PCM frame: %w", err) + } + if len(encoded) == 0 { + return nil + } + defer zeroBytes(encoded) + if err = s.sender(s.instanceID, s.callID, encoded, uint32(s.codec.FrameSize()), s.marker); err != nil { + return fmt.Errorf("send encoded audio frame: %w", err) + } + s.marker = false + return nil +} + +func (s *audioSession) silenceLoop() { + defer close(s.doneCh) + ticker := time.NewTicker(s.silenceTick) + defer ticker.Stop() + silence := make([]float32, s.codec.FrameSize()) + defer zeroFloat32(silence) + + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + idle := time.Since(s.lastCapture) >= s.silenceAfter + ready := s.codec != nil && s.sender != nil + if idle && ready { + _ = s.encodeAndSendLocked(silence) + } + s.mu.Unlock() + } + } +} + +func (s *audioSession) close() { + if s == nil { + return + } + s.stopOnce.Do(func() { close(s.stopCh) }) + <-s.doneCh + + s.mu.Lock() + if !s.closed { + s.closed = true + if s.codec != nil { + s.codec.Close() + } + zeroFloat32(s.encodeBuffer) + s.codec = nil + s.sender = nil + s.encodeBuffer = nil + s.encodePos = 0 + s.marker = false + s.lastCapture = time.Time{} + } + s.mu.Unlock() +} + +func sanitizePCMSample(sample float32) float32 { + value := float64(sample) + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + if sample > 1 { + return 1 + } + if sample < -1 { + return -1 + } + return sample +} + +// AudioRegistry owns one codec and PCM accumulator per call. It is independent +// from HTTP and device APIs so browser, native and test bridges can reuse it. +type AudioRegistry struct { + mu sync.RWMutex + + options AudioRegistryOptions + sender EncodedAudioSender + sessions map[string]map[string]*audioSession + onPCM PCMCallback +} + +func NewAudioRegistry(sender EncodedAudioSender, options *AudioRegistryOptions) *AudioRegistry { + resolved := DefaultAudioRegistryOptions() + if options != nil { + resolved = *options + if resolved.CodecFactory == nil { + resolved.CodecFactory = NewMLowCodec + } + if resolved.SilenceTick == 0 { + resolved.SilenceTick = 60 * time.Millisecond + } + if resolved.SilenceAfter == 0 { + resolved.SilenceAfter = 120 * time.Millisecond + } + } + return &AudioRegistry{ + options: resolved, + sender: sender, + sessions: make(map[string]map[string]*audioSession), + } +} + +func (r *AudioRegistry) SetOnPCM(callback PCMCallback) { + if r == nil { + return + } + r.mu.Lock() + r.onPCM = callback + r.mu.Unlock() +} + +func (r *AudioRegistry) Prepare(instanceID, callID string) error { + if r == nil || instanceID == "" || callID == "" { + return ErrAudioSessionNotReady + } + r.mu.RLock() + if calls := r.sessions[instanceID]; calls != nil && calls[callID] != nil { + r.mu.RUnlock() + return nil + } + factory := r.options.CodecFactory + r.mu.RUnlock() + if factory == nil { + return ErrAudioSessionNotReady + } + + codec, err := factory(r.options.CodecOptions) + if err != nil { + return fmt.Errorf("create MLow codec: %w", err) + } + candidate := newAudioSession(instanceID, callID, codec, r.sender, r.options) + + r.mu.Lock() + calls := r.sessions[instanceID] + if calls == nil { + calls = make(map[string]*audioSession) + r.sessions[instanceID] = calls + } + if existing := calls[callID]; existing != nil { + r.mu.Unlock() + candidate.close() + return nil + } + calls[callID] = candidate + r.mu.Unlock() + return nil +} + +func (r *AudioRegistry) FeedPCM(instanceID, callID string, pcm []float32) error { + session, err := r.session(instanceID, callID, true) + if err != nil { + return err + } + return session.feedPCM(pcm) +} + +func (r *AudioRegistry) HandleRTP(instanceID, callID string, packet *RTPPacket) error { + session, err := r.session(instanceID, callID, true) + if err != nil { + return err + } + pcm, err := session.handleRTP(packet) + if err != nil { + return err + } + defer zeroFloat32(pcm) + + r.mu.RLock() + callback := r.onPCM + r.mu.RUnlock() + if callback != nil { + callback(instanceID, callID, append([]float32(nil), pcm...)) + } + return nil +} + +func (r *AudioRegistry) session(instanceID, callID string, lazy bool) (*audioSession, error) { + if r == nil { + return nil, ErrAudioSessionNotReady + } + r.mu.RLock() + calls := r.sessions[instanceID] + session := calls[callID] + r.mu.RUnlock() + if session != nil { + return session, nil + } + if lazy { + if err := r.Prepare(instanceID, callID); err != nil { + return nil, err + } + r.mu.RLock() + session = r.sessions[instanceID][callID] + r.mu.RUnlock() + if session != nil { + return session, nil + } + } + return nil, ErrAudioSessionNotReady +} + +func (r *AudioRegistry) Remove(instanceID, callID string) { + if r == nil { + return + } + r.mu.Lock() + calls := r.sessions[instanceID] + session := calls[callID] + delete(calls, callID) + if len(calls) == 0 { + delete(r.sessions, instanceID) + } + r.mu.Unlock() + if session != nil { + session.close() + } +} + +func (r *AudioRegistry) Close(instanceID string) { + if r == nil { + return + } + r.mu.Lock() + sessions := r.sessions[instanceID] + delete(r.sessions, instanceID) + r.mu.Unlock() + for callID, session := range sessions { + if session != nil { + session.close() + } + delete(sessions, callID) + } +} From a603321b01f7ac487775706b75fb53a57a02f276 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:45:38 -0300 Subject: [PATCH 110/266] test(call): cover PCM codec pipeline --- pkg/call/voip/media/audio_pipeline_test.go | 211 +++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 pkg/call/voip/media/audio_pipeline_test.go diff --git a/pkg/call/voip/media/audio_pipeline_test.go b/pkg/call/voip/media/audio_pipeline_test.go new file mode 100644 index 00000000..2b2f2ba5 --- /dev/null +++ b/pkg/call/voip/media/audio_pipeline_test.go @@ -0,0 +1,211 @@ +package media + +import ( + "math" + "sync" + "testing" + "time" +) + +type fakeAudioCodec struct { + mu sync.Mutex + frames [][]float32 + closed bool + decoded float32 +} + +func (c *fakeAudioCodec) Encode(pcm []float32) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + frame := append([]float32(nil), pcm...) + c.frames = append(c.frames, frame) + return []byte{byte(len(c.frames)), 0x7f}, nil +} + +func (c *fakeAudioCodec) Decode(frame []byte) ([]float32, error) { + c.mu.Lock() + defer c.mu.Unlock() + pcm := make([]float32, MLowFrameSize) + for index := range pcm { + pcm[index] = c.decoded + } + return pcm, nil +} + +func (c *fakeAudioCodec) FrameSize() int { return MLowFrameSize } +func (c *fakeAudioCodec) SampleRate() int { return MLowSampleRate } +func (c *fakeAudioCodec) Close() { + c.mu.Lock() + c.closed = true + c.mu.Unlock() +} + +type sentAudioFrame struct { + payload []byte + duration uint32 + marker bool +} + +func TestAudioRegistryBuffersSanitizesAndSendsPCM(t *testing.T) { + codec := &fakeAudioCodec{} + var mu sync.Mutex + var sent []sentAudioFrame + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + DisableSilence: true, + } + registry := NewAudioRegistry(func(_ string, _ string, payload []byte, duration uint32, marker bool) error { + mu.Lock() + sent = append(sent, sentAudioFrame{payload: append([]byte(nil), payload...), duration: duration, marker: marker}) + mu.Unlock() + return nil + }, options) + defer registry.Close("instance") + + first := make([]float32, MLowFrameSize/2) + first[0] = float32(math.NaN()) + first[1] = 2 + if err := registry.FeedPCM("instance", "call", first); err != nil { + t.Fatal(err) + } + mu.Lock() + if len(sent) != 0 { + t.Fatalf("partial PCM unexpectedly sent %d frames", len(sent)) + } + mu.Unlock() + + second := make([]float32, MLowFrameSize/2) + second[0] = -2 + if err := registry.FeedPCM("instance", "call", second); err != nil { + t.Fatal(err) + } + if err := registry.FeedPCM("instance", "call", make([]float32, MLowFrameSize)); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + if len(sent) != 2 { + t.Fatalf("sent %d frames, want 2", len(sent)) + } + if !sent[0].marker || sent[1].marker { + t.Fatalf("unexpected marker sequence: %+v", sent) + } + if sent[0].duration != MLowFrameSize { + t.Fatalf("duration=%d, want %d", sent[0].duration, MLowFrameSize) + } + + codec.mu.Lock() + defer codec.mu.Unlock() + if len(codec.frames) != 2 { + t.Fatalf("codec received %d frames", len(codec.frames)) + } + if codec.frames[0][0] != 0 || codec.frames[0][1] != 1 || codec.frames[0][MLowFrameSize/2] != -1 { + t.Fatalf("PCM sanitization failed: %v %v %v", codec.frames[0][0], codec.frames[0][1], codec.frames[0][MLowFrameSize/2]) + } +} + +func TestAudioRegistryDecodesRTPToPCMCallback(t *testing.T) { + codec := &fakeAudioCodec{decoded: 0.25} + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + DisableSilence: true, + } + registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) + defer registry.Close("instance") + + called := make(chan []float32, 1) + registry.SetOnPCM(func(instanceID, callID string, pcm []float32) { + if instanceID != "instance" || callID != "call" { + t.Errorf("unexpected callback identity %s/%s", instanceID, callID) + } + called <- append([]float32(nil), pcm...) + }) + packet := &RTPPacket{Header: &RTPHeader{Version: 2}, Payload: []byte{1, 2, 3}} + if err := registry.HandleRTP("instance", "call", packet); err != nil { + t.Fatal(err) + } + select { + case pcm := <-called: + if len(pcm) != MLowFrameSize || pcm[0] != 0.25 { + t.Fatalf("unexpected decoded PCM: len=%d first=%v", len(pcm), pcm[0]) + } + case <-time.After(time.Second): + t.Fatal("PCM callback was not invoked") + } +} + +func TestAudioRegistrySendsSilenceWhileIdle(t *testing.T) { + codec := &fakeAudioCodec{} + sent := make(chan sentAudioFrame, 4) + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + SilenceTick: 5 * time.Millisecond, + SilenceAfter: 5 * time.Millisecond, + } + registry := NewAudioRegistry(func(_ string, _ string, payload []byte, duration uint32, marker bool) error { + sent <- sentAudioFrame{payload: append([]byte(nil), payload...), duration: duration, marker: marker} + return nil + }, options) + if err := registry.Prepare("instance", "call"); err != nil { + t.Fatal(err) + } + defer registry.Close("instance") + + select { + case frame := <-sent: + if !frame.marker || frame.duration != MLowFrameSize { + t.Fatalf("unexpected silence frame: %+v", frame) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("silence keepalive was not sent") + } +} + +func TestAudioRegistryRemoveWaitsForInFlightSend(t *testing.T) { + codec := &fakeAudioCodec{} + entered := make(chan struct{}) + release := make(chan struct{}) + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + DisableSilence: true, + } + registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { + close(entered) + <-release + return nil + }, options) + + feedDone := make(chan error, 1) + go func() { + feedDone <- registry.FeedPCM("instance", "call", make([]float32, MLowFrameSize)) + }() + <-entered + removeDone := make(chan struct{}) + go func() { + registry.Remove("instance", "call") + close(removeDone) + }() + + select { + case <-removeDone: + t.Fatal("Remove returned while the sender was still in flight") + case <-time.After(20 * time.Millisecond): + } + close(release) + if err := <-feedDone; err != nil { + t.Fatal(err) + } + select { + case <-removeDone: + case <-time.After(time.Second): + t.Fatal("Remove did not finish after the sender returned") + } + + codec.mu.Lock() + closed := codec.closed + codec.mu.Unlock() + if !closed { + t.Fatal("codec was not closed") + } +} From 60d7ba3683477bac7beb4ebfcfedcb6f54dcfbfd Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:46:08 -0300 Subject: [PATCH 111/266] feat(call): connect PCM codec pipeline to call lifecycle --- pkg/call/lifecycle/coordinator.go | 68 +++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go index 2be73582..1f1e3a9b 100644 --- a/pkg/call/lifecycle/coordinator.go +++ b/pkg/call/lifecycle/coordinator.go @@ -18,11 +18,15 @@ import ( // Coordinator owns the call registries shared by the WhatsApp lifecycle and // the HTTP call service. It is safe for concurrent use. type Coordinator struct { - mu sync.RWMutex - runtimes *call_runtime.Registry - incoming *call_incoming.Registry - relays *call_media.RelayRegistry - packets *call_media.PacketRegistry + mu sync.RWMutex + + runtimes *call_runtime.Registry + incoming *call_incoming.Registry + relays *call_media.RelayRegistry + packets *call_media.PacketRegistry + audio *call_media.AudioRegistry + onRTP func(instanceID, callID string, packet *call_media.RTPPacket) + incomingEnabled map[string]bool } @@ -35,15 +39,31 @@ func NewCoordinator() *Coordinator { packets: packets, incomingEnabled: make(map[string]bool), } + coordinator.audio = call_media.NewAudioRegistry(func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { + return coordinator.SendOpus(instanceID, callID, payload, durationSamples, marker) + }, nil) coordinator.relays = call_media.NewRelayRegistry(incoming, nil, nil) coordinator.relays.SetOnConnected(func(instanceID, callID string) { if err := coordinator.packets.Prepare(instanceID, callID); err != nil { return } + if err := coordinator.audio.Prepare(instanceID, callID); err != nil { + coordinator.packets.Remove(instanceID, callID) + return + } if runtime, ok := coordinator.runtimes.Get(instanceID); ok { runtime.Transition(callID, "", "", call_runtime.StateActive, nil, "") } }) + coordinator.packets.SetOnRTP(func(instanceID, callID string, packet *call_media.RTPPacket) { + _ = coordinator.audio.HandleRTP(instanceID, callID, packet) + coordinator.mu.RLock() + callback := coordinator.onRTP + coordinator.mu.RUnlock() + if callback != nil { + callback(instanceID, callID, packet) + } + }) coordinator.relays.SetOnPacket(func(instanceID, callID string, packet []byte) { err := coordinator.packets.Handle(instanceID, callID, packet) if errors.Is(err, call_media.ErrNonRTPFrame) || errors.Is(err, call_media.ErrPacketSessionNotReady) { @@ -71,8 +91,8 @@ func (c *Coordinator) AttachClient(instanceID string, client *whatsmeow.Client, c.relays.Attach(instanceID, client) } -// DetachClient removes handlers, relay connections, packet contexts, -// configuration and private call keys before the WhatsApp client is discarded. +// DetachClient removes handlers, relay connections, codec sessions, packet +// contexts, configuration and private call keys before the client is discarded. func (c *Coordinator) DetachClient(instanceID string) { if c == nil || instanceID == "" { return @@ -81,6 +101,7 @@ func (c *Coordinator) DetachClient(instanceID string) { delete(c.incomingEnabled, instanceID) c.mu.Unlock() c.relays.Close(instanceID) + c.audio.Close(instanceID) c.packets.Close(instanceID) c.runtimes.Remove(instanceID) c.incoming.Close(instanceID) @@ -154,13 +175,30 @@ func (c *Coordinator) TerminateIncoming(ctx context.Context, instanceID, callID return err } c.relays.Remove(instanceID, callID) + c.audio.Remove(instanceID, callID) c.packets.Remove(instanceID, callID) return nil } -// SendOpus protects one encoded Opus frame as SRTP and broadcasts it through -// the currently connected WhatsApp relays. It is an internal media boundary; -// no HTTP endpoint exposes raw audio frames in this milestone. +// FeedPCM accepts mono float PCM at 16 kHz. Samples may arrive in arbitrary +// chunk sizes; the audio registry accumulates complete 960-sample MLow frames. +func (c *Coordinator) FeedPCM(instanceID, callID string, pcm []float32) error { + if c == nil { + return call_media.ErrAudioSessionNotReady + } + return c.audio.FeedPCM(instanceID, callID, pcm) +} + +// SetOnPCM registers the internal decoded-audio sink used by a future WebRTC, +// native playback or test bridge. The callback receives an owned PCM copy. +func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { + if c != nil { + c.audio.SetOnPCM(callback) + } +} + +// SendOpus protects one encoded MLow/Opus-compatible frame as SRTP and +// broadcasts it through the currently connected WhatsApp relays. func (c *Coordinator) SendOpus(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { if c == nil { return call_media.ErrPacketSessionNotReady @@ -173,14 +211,20 @@ func (c *Coordinator) SendOpus(instanceID, callID string, payload []byte, durati return c.relays.Broadcast(instanceID, callID, protected) } +// SetOnRTP keeps the low-level authenticated RTP observation hook while the +// internal decoder remains permanently connected. func (c *Coordinator) SetOnRTP(callback func(instanceID, callID string, packet *call_media.RTPPacket)) { - if c != nil { - c.packets.SetOnRTP(callback) + if c == nil { + return } + c.mu.Lock() + c.onRTP = callback + c.mu.Unlock() } func (c *Coordinator) RemovePrivate(instanceID, callID string) { c.relays.Remove(instanceID, callID) + c.audio.Remove(instanceID, callID) c.packets.Remove(instanceID, callID) c.incoming.Remove(instanceID, callID) } From 18730cf4cef8425c580b397321890716d94e7d72 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:48:10 -0300 Subject: [PATCH 112/266] docs(call): document MLow PCM pipeline --- docs/wiki/guias-api/api-calls-experimental.md | 65 ++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md index 18ec03a8..3f7baf0e 100644 --- a/docs/wiki/guias-api/api-calls-experimental.md +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -2,7 +2,7 @@ Esta branch adiciona a integração experimental WaCalls/AstraCalls ao Evolution Go. -> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante `voip_pion` negocia DataChannels com os relays e processa pacotes RTP/SRTP autenticados. Ainda não há áudio reproduzível porque codecs, PCM e a ponte WebRTC não foram conectados. Não use em produção. +> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante `voip_pion` negocia DataChannels com os relays, processa RTP/SRTP autenticado e possui codec MLow com entrada e saída PCM internas. Ainda não há áudio audível para o usuário porque microfone, reprodução e ponte WebRTC não foram conectados. Não use em produção. Todas as rotas usam a autenticação normal da instância do Evolution. @@ -12,7 +12,7 @@ Todas as rotas usam a autenticação normal da instância do Evolution. GET /call/status ``` -Os monitores são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels, sessões RTP/SRTP e material privado são removidos antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento. +Os monitores são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels, sessões RTP/SRTP, codecs e material privado são removidos antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento. Exemplo de resposta: @@ -24,7 +24,7 @@ Exemplo de resposta: } ``` -Chaves de chamada, JIDs internos de dispositivos, chaves SRTP, tokens de relay e outros dados privados não fazem parte dessa resposta. Eles ficam somente em memória e são apagados quando a chamada termina, é rejeitada ou a sessão é desconectada. +Chaves de chamada, JIDs internos de dispositivos, chaves SRTP, tokens de relay e buffers PCM não fazem parte dessa resposta. Eles ficam somente em memória e são apagados quando a chamada termina, é rejeitada ou a sessão é desconectada. Instâncias configuradas com rejeição automática continuam rastreando o estado público da chamada. Elas não descriptografam ofertas recebidas nem enviam `preaccept`, mas continuam podendo iniciar chamadas e armazenar sua negociação privada de saída. @@ -80,7 +80,7 @@ O runtime descriptografa a chave recebida usando a sessão Signal já autenticad } ``` -`CallAccept` não marca a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que o DataChannel abre e a sessão RTP/SRTP por chamada é criada com sucesso. +`CallAccept` não marca a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que o DataChannel abre e as sessões RTP/SRTP e MLow são criadas com sucesso. Se a preparação criptográfica do evento ainda não terminou, a API retorna um erro informando que a chamada ainda não está pronta para aceite. @@ -91,7 +91,7 @@ DELETE /call/{callId} apikey: INSTANCE_TOKEN ``` -A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove chave, candidatos, DataChannels, contextos RTP/SRTP e demais dados privados da chamada. +A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove chave, candidatos, DataChannels, contextos RTP/SRTP, codec, buffers PCM e demais dados privados da chamada. ## Rejeitar uma chamada recebida @@ -155,7 +155,7 @@ A implementação experimental Pion inclui: ## RTP e SRTP -O caminho de pacotes agora inclui: +O caminho de pacotes inclui: - RTP versão 2 com CSRC, extensões e padding validados; - gerador concorrente de sequência e timestamp para payload type `120`; @@ -168,13 +168,47 @@ O caminho de pacotes agora inclui: - janela antirreplay de 64 pacotes; - suporte a pacotes autenticados fora de ordem dentro da janela; - rejeição de reutilização do índice SRTP no envio; -- validação do SSRC remoto e do payload type Opus; +- validação do SSRC remoto e do payload type de áudio; - sessão independente por `callId`; - limpeza sincronizada durante término, rejeição, logout ou reconexão. -O `Coordinator` já possui uma fronteira interna para proteger um frame Opus e transmiti-lo pelos relays. Ela ainda não é exposta em HTTP porque falta conectar encoder/decoder e PCM. +## Codec MLow e PCM -A build padrão continua usando um transportador sem rede. Para compilar a variante experimental: +O codec MLow em Go puro foi portado da revisão MIT fixada `edeb31f0427aba896639db503153b777a405eccf` do WaCalls. O runtime não depende de CGO ou de uma instalação externa de `libopus` para essa etapa. + +O pipeline interno agora: + +- aceita PCM mono `float32` em 16 kHz; +- aceita chunks de tamanho arbitrário; +- acumula frames completos de 960 amostras, equivalentes a 60 ms; +- substitui `NaN` e infinito por silêncio; +- limita amplitudes ao intervalo `[-1, 1]`; +- codifica MLow e envia o payload pelo RTP/SRTP existente; +- preserva o marker RTP no primeiro frame transmitido; +- envia frames de silêncio quando a captura fica inativa; +- decodifica payloads recebidos para blocos PCM de 960 amostras; +- entrega uma cópia do PCM a um callback interno; +- serializa encoder e decoder por chamada; +- espera envios em andamento antes do teardown. + +As fronteiras internas disponíveis no `Coordinator` são: + +- `FeedPCM(instanceID, callID, pcm)` para PCM mono/16 kHz; +- `SetOnPCM(callback)` para receber PCM decodificado; +- `SetOnRTP(callback)` para observação autenticada de baixo nível; +- `SendOpus(...)` para o caminho codificado já existente. + +Essas funções ainda não são rotas HTTP. Expor áudio bruto sem autenticação de mídia, limite de fluxo e controle de sessão aumentaria a superfície de ataque. + +## Build experimental + +A build padrão continua usando um transportador sem rede: + +```bash +go build ./cmd/evolution-go +``` + +Para compilar a variante com relay Pion: ```bash go build -tags=voip_pion ./cmd/evolution-go @@ -189,11 +223,12 @@ go test -race -tags=voip_pion ./pkg/call/... ## Limitações atuais -- sem áudio bidirecional reproduzível; -- sem encoder/decoder Opus ou MLow conectado ao runtime; -- sem captura e reprodução PCM; -- sem WebRTC para navegador; -- o caminho interno de envio Opus ainda não possui endpoint público; +- sem captura real de microfone; +- sem reprodução em alto-falante; +- sem ponte WebRTC para navegador; +- sem resampling automático para fontes que não sejam mono/16 kHz; +- sem jitter buffer adaptativo ou concealment coordenado por perda de RTP; +- sem endpoint ou protocolo público de streaming de áudio; - a conexão real com um relay WhatsApp ainda precisa ser validada de ponta a ponta com uma conta conectada; -- as chaves ficam somente em memória e não sobrevivem a reinícios; +- as chaves e sessões ficam somente em memória e não sobrevivem a reinícios; - API e formatos podem mudar enquanto o PR estiver em rascunho. From 7c7b68aff0862f820b656687dde41482482e489e Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:48:40 -0300 Subject: [PATCH 113/266] docs(call): clarify internal audio readiness From f663d3a7ed6f8ccd335837104123f27761353c9f Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:49:09 -0300 Subject: [PATCH 114/266] docs(call): keep PCM pipeline documentation synchronized From d1348b1f23b9317e6d05f4b40ebc0632a8c4a24a Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:53:17 -0300 Subject: [PATCH 115/266] feat(call): add bounded RTP jitter buffer --- pkg/call/voip/media/jitter_buffer.go | 324 +++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 pkg/call/voip/media/jitter_buffer.go diff --git a/pkg/call/voip/media/jitter_buffer.go b/pkg/call/voip/media/jitter_buffer.go new file mode 100644 index 00000000..cc1e611e --- /dev/null +++ b/pkg/call/voip/media/jitter_buffer.go @@ -0,0 +1,324 @@ +// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS. +package media + +import ( + "errors" + "fmt" + "sync" + "time" +) + +var ( + ErrJitterBufferClosed = errors.New("jitter buffer is closed") + ErrJitterDuplicatePacket = errors.New("duplicate RTP packet") + ErrJitterLatePacket = errors.New("RTP packet arrived after its playout deadline") + ErrJitterBufferFull = errors.New("jitter buffer is full") +) + +type JitterBufferOptions struct { + FrameDuration time.Duration + InitialDelayPackets int + MaxPackets int + MaxConcealmentPackets int +} + +func DefaultJitterBufferOptions() JitterBufferOptions { + return JitterBufferOptions{ + FrameDuration: 60 * time.Millisecond, + InitialDelayPackets: 2, + MaxPackets: 64, + MaxConcealmentPackets: 5, + } +} + +type JitterFrame struct { + SequenceNumber uint16 + Timestamp uint32 + Marker bool + Payload []byte + Concealed bool +} + +type JitterBufferStats struct { + Received uint64 + Delivered uint64 + Concealed uint64 + Duplicate uint64 + Late uint64 + Overflow uint64 +} + +type bufferedRTP struct { + extendedSequence uint64 + sequenceNumber uint16 + timestamp uint32 + marker bool + payload []byte +} + +type JitterBuffer struct { + mu sync.Mutex + + options JitterBufferOptions + onFrame func(JitterFrame) + packets map[uint64]*bufferedRTP + + initialized bool + started bool + highestSequence uint64 + nextSequence uint64 + firstArrival time.Time + consecutiveMissing int + closed bool + stats JitterBufferStats + + stopCh chan struct{} + doneCh chan struct{} + stopOnce sync.Once +} + +func NewJitterBuffer(options *JitterBufferOptions, onFrame func(JitterFrame)) *JitterBuffer { + resolved := DefaultJitterBufferOptions() + if options != nil { + resolved = *options + } + if resolved.FrameDuration <= 0 { + resolved.FrameDuration = 60 * time.Millisecond + } + if resolved.InitialDelayPackets <= 0 { + resolved.InitialDelayPackets = 1 + } + if resolved.MaxPackets <= 0 { + resolved.MaxPackets = 64 + } + if resolved.MaxConcealmentPackets <= 0 { + resolved.MaxConcealmentPackets = 1 + } + + buffer := &JitterBuffer{ + options: resolved, + onFrame: onFrame, + packets: make(map[uint64]*bufferedRTP), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } + go buffer.playoutLoop() + return buffer +} + +func (b *JitterBuffer) Push(packet *RTPPacket) error { + if b == nil { + return ErrJitterBufferClosed + } + if packet == nil || packet.Header == nil { + return fmt.Errorf("push jitter packet: RTP packet or header is nil") + } + + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrJitterBufferClosed + } + + extended := b.extendSequenceLocked(packet.Header.SequenceNumber) + if b.started && extended < b.nextSequence { + b.stats.Late++ + return ErrJitterLatePacket + } + if _, exists := b.packets[extended]; exists { + b.stats.Duplicate++ + return ErrJitterDuplicatePacket + } + if len(b.packets) >= b.options.MaxPackets { + b.stats.Overflow++ + return ErrJitterBufferFull + } + + if !b.initialized { + b.initialized = true + b.highestSequence = extended + b.firstArrival = time.Now() + } else if extended > b.highestSequence { + b.highestSequence = extended + } + + b.packets[extended] = &bufferedRTP{ + extendedSequence: extended, + sequenceNumber: packet.Header.SequenceNumber, + timestamp: packet.Header.Timestamp, + marker: packet.Header.Marker, + payload: append([]byte(nil), packet.Payload...), + } + b.stats.Received++ + + if !b.started && len(b.packets) >= b.options.InitialDelayPackets { + b.startLocked() + } + return nil +} + +func (b *JitterBuffer) Stats() JitterBufferStats { + if b == nil { + return JitterBufferStats{} + } + b.mu.Lock() + stats := b.stats + b.mu.Unlock() + return stats +} + +func (b *JitterBuffer) Buffered() int { + if b == nil { + return 0 + } + b.mu.Lock() + count := len(b.packets) + b.mu.Unlock() + return count +} + +func (b *JitterBuffer) Close() { + if b == nil { + return + } + b.stopOnce.Do(func() { close(b.stopCh) }) + <-b.doneCh +} + +func (b *JitterBuffer) playoutLoop() { + defer close(b.doneCh) + ticker := time.NewTicker(b.options.FrameDuration) + defer ticker.Stop() + + for { + select { + case <-b.stopCh: + b.mu.Lock() + b.closed = true + b.clearPacketsLocked() + b.mu.Unlock() + return + case now := <-ticker.C: + frame, ok := b.dequeue(now) + if !ok { + continue + } + if b.onFrame != nil { + b.onFrame(frame) + } + zeroBytes(frame.Payload) + } + } +} + +func (b *JitterBuffer) dequeue(now time.Time) (JitterFrame, bool) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed || !b.initialized { + return JitterFrame{}, false + } + if !b.started { + startupDelay := time.Duration(b.options.InitialDelayPackets) * b.options.FrameDuration + if len(b.packets) < b.options.InitialDelayPackets && now.Sub(b.firstArrival) < startupDelay { + return JitterFrame{}, false + } + b.startLocked() + } + + if packet := b.packets[b.nextSequence]; packet != nil { + delete(b.packets, b.nextSequence) + frame := JitterFrame{ + SequenceNumber: packet.sequenceNumber, + Timestamp: packet.timestamp, + Marker: packet.marker, + Payload: packet.payload, + } + packet.payload = nil + b.nextSequence++ + b.consecutiveMissing = 0 + b.stats.Delivered++ + return frame, true + } + + sequence := uint16(b.nextSequence) + timestamp := b.estimatedTimestampLocked() + b.nextSequence++ + b.consecutiveMissing++ + b.stats.Concealed++ + frame := JitterFrame{SequenceNumber: sequence, Timestamp: timestamp, Concealed: true} + + if b.consecutiveMissing >= b.options.MaxConcealmentPackets { + b.resynchronizeLocked() + } + return frame, true +} + +func (b *JitterBuffer) startLocked() { + if len(b.packets) == 0 { + return + } + b.nextSequence = b.minimumSequenceLocked() + b.started = true + b.consecutiveMissing = 0 +} + +func (b *JitterBuffer) resynchronizeLocked() { + b.consecutiveMissing = 0 + if len(b.packets) == 0 { + b.initialized = false + b.started = false + b.highestSequence = 0 + b.nextSequence = 0 + b.firstArrival = time.Time{} + return + } + b.nextSequence = b.minimumSequenceLocked() +} + +func (b *JitterBuffer) extendSequenceLocked(sequence uint16) uint64 { + if !b.initialized { + return uint64(sequence) + } + rollover := b.highestSequence >> 16 + highestLow := uint16(b.highestSequence) + candidate := rollover<<16 | uint64(sequence) + + if sequence < highestLow && highestLow-sequence > 0x8000 { + candidate += 1 << 16 + } else if sequence > highestLow && sequence-highestLow > 0x8000 && rollover > 0 { + candidate -= 1 << 16 + } + return candidate +} + +func (b *JitterBuffer) minimumSequenceLocked() uint64 { + var minimum uint64 + first := true + for sequence := range b.packets { + if first || sequence < minimum { + minimum = sequence + first = false + } + } + return minimum +} + +func (b *JitterBuffer) estimatedTimestampLocked() uint32 { + if next := b.packets[b.nextSequence+1]; next != nil { + return next.timestamp - uint32(MLowFrameSize) + } + if previous := b.packets[b.nextSequence-1]; previous != nil { + return previous.timestamp + uint32(MLowFrameSize) + } + return 0 +} + +func (b *JitterBuffer) clearPacketsLocked() { + for sequence, packet := range b.packets { + if packet != nil { + zeroBytes(packet.payload) + packet.payload = nil + } + delete(b.packets, sequence) + } +} From 3c4acb6f4d11c5e0bcc37fa4e7c3b049c5fdbd5c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:53:44 -0300 Subject: [PATCH 116/266] test(call): cover jitter ordering and loss concealment --- pkg/call/voip/media/jitter_buffer_test.go | 202 ++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 pkg/call/voip/media/jitter_buffer_test.go diff --git a/pkg/call/voip/media/jitter_buffer_test.go b/pkg/call/voip/media/jitter_buffer_test.go new file mode 100644 index 00000000..83bc2482 --- /dev/null +++ b/pkg/call/voip/media/jitter_buffer_test.go @@ -0,0 +1,202 @@ +package media + +import ( + "errors" + "reflect" + "testing" + "time" +) + +func testJitterOptions() JitterBufferOptions { + return JitterBufferOptions{ + FrameDuration: 3 * time.Millisecond, + InitialDelayPackets: 2, + MaxPackets: 8, + MaxConcealmentPackets: 2, + } +} + +func jitterPacket(sequence uint16, timestamp uint32, value byte) *RTPPacket { + return &RTPPacket{ + Header: &RTPHeader{ + Version: 2, + SequenceNumber: sequence, + Timestamp: timestamp, + }, + Payload: []byte{value}, + } +} + +func readJitterFrames(t *testing.T, frames <-chan JitterFrame, count int) []JitterFrame { + t.Helper() + result := make([]JitterFrame, 0, count) + deadline := time.After(time.Second) + for len(result) < count { + select { + case frame := <-frames: + result = append(result, frame) + case <-deadline: + t.Fatalf("timed out after %d/%d jitter frames", len(result), count) + } + } + return result +} + +func TestJitterBufferReordersPackets(t *testing.T) { + options := testJitterOptions() + frames := make(chan JitterFrame, 4) + buffer := NewJitterBuffer(&options, func(frame JitterFrame) { + frame.Payload = append([]byte(nil), frame.Payload...) + frames <- frame + }) + defer buffer.Close() + + if err := buffer.Push(jitterPacket(11, 1960, 11)); err != nil { + t.Fatal(err) + } + if err := buffer.Push(jitterPacket(10, 1000, 10)); err != nil { + t.Fatal(err) + } + + got := readJitterFrames(t, frames, 2) + if got[0].SequenceNumber != 10 || got[1].SequenceNumber != 11 { + t.Fatalf("unexpected order: %d, %d", got[0].SequenceNumber, got[1].SequenceNumber) + } + if !reflect.DeepEqual(got[0].Payload, []byte{10}) || !reflect.DeepEqual(got[1].Payload, []byte{11}) { + t.Fatalf("unexpected payloads: %v %v", got[0].Payload, got[1].Payload) + } +} + +func TestJitterBufferConcealsGapThenContinues(t *testing.T) { + options := testJitterOptions() + frames := make(chan JitterFrame, 6) + buffer := NewJitterBuffer(&options, func(frame JitterFrame) { + frame.Payload = append([]byte(nil), frame.Payload...) + frames <- frame + }) + defer buffer.Close() + + if err := buffer.Push(jitterPacket(20, 1000, 20)); err != nil { + t.Fatal(err) + } + if err := buffer.Push(jitterPacket(22, 2920, 22)); err != nil { + t.Fatal(err) + } + + got := readJitterFrames(t, frames, 3) + if got[0].SequenceNumber != 20 || got[0].Concealed { + t.Fatalf("unexpected first frame: %+v", got[0]) + } + if got[1].SequenceNumber != 21 || !got[1].Concealed || len(got[1].Payload) != 0 { + t.Fatalf("missing packet was not concealed: %+v", got[1]) + } + if got[1].Timestamp != 1960 { + t.Fatalf("concealed timestamp=%d, want 1960", got[1].Timestamp) + } + if got[2].SequenceNumber != 22 || got[2].Concealed { + t.Fatalf("unexpected recovery frame: %+v", got[2]) + } + + stats := buffer.Stats() + if stats.Delivered != 2 || stats.Concealed != 1 { + t.Fatalf("unexpected stats: %+v", stats) + } +} + +func TestJitterBufferHandlesSequenceRollover(t *testing.T) { + options := testJitterOptions() + frames := make(chan JitterFrame, 4) + buffer := NewJitterBuffer(&options, func(frame JitterFrame) { frames <- frame }) + defer buffer.Close() + + if err := buffer.Push(jitterPacket(0, 1960, 0)); err != nil { + t.Fatal(err) + } + if err := buffer.Push(jitterPacket(65535, 1000, 1)); err != nil { + t.Fatal(err) + } + + got := readJitterFrames(t, frames, 2) + if got[0].SequenceNumber != 65535 || got[1].SequenceNumber != 0 { + t.Fatalf("rollover order is %d, %d", got[0].SequenceNumber, got[1].SequenceNumber) + } +} + +func TestJitterBufferRejectsDuplicateLateAndOverflow(t *testing.T) { + options := testJitterOptions() + options.MaxPackets = 2 + frames := make(chan JitterFrame, 4) + buffer := NewJitterBuffer(&options, func(frame JitterFrame) { frames <- frame }) + defer buffer.Close() + + packet := jitterPacket(30, 1000, 1) + if err := buffer.Push(packet); err != nil { + t.Fatal(err) + } + if err := buffer.Push(packet); !errors.Is(err, ErrJitterDuplicatePacket) { + t.Fatalf("expected duplicate error, got %v", err) + } + if err := buffer.Push(jitterPacket(31, 1960, 2)); err != nil { + t.Fatal(err) + } + if err := buffer.Push(jitterPacket(32, 2920, 3)); !errors.Is(err, ErrJitterBufferFull) { + t.Fatalf("expected full error, got %v", err) + } + + _ = readJitterFrames(t, frames, 2) + if err := buffer.Push(jitterPacket(30, 1000, 1)); !errors.Is(err, ErrJitterLatePacket) { + t.Fatalf("expected late error, got %v", err) + } + + stats := buffer.Stats() + if stats.Duplicate != 1 || stats.Overflow != 1 || stats.Late != 1 { + t.Fatalf("unexpected stats: %+v", stats) + } +} + +func TestJitterBufferStopsAfterBoundedConcealment(t *testing.T) { + options := testJitterOptions() + options.InitialDelayPackets = 1 + frames := make(chan JitterFrame, 8) + buffer := NewJitterBuffer(&options, func(frame JitterFrame) { frames <- frame }) + defer buffer.Close() + + if err := buffer.Push(jitterPacket(40, 1000, 1)); err != nil { + t.Fatal(err) + } + got := readJitterFrames(t, frames, 3) + if got[0].Concealed || !got[1].Concealed || !got[2].Concealed { + t.Fatalf("unexpected concealment sequence: %+v", got) + } + + select { + case extra := <-frames: + t.Fatalf("unbounded concealment produced extra frame: %+v", extra) + case <-time.After(25 * time.Millisecond): + } +} + +func TestJitterBufferCopiesAndWipesOwnedPayload(t *testing.T) { + options := testJitterOptions() + options.InitialDelayPackets = 1 + frames := make(chan JitterFrame, 1) + buffer := NewJitterBuffer(&options, func(frame JitterFrame) { + frame.Payload = append([]byte(nil), frame.Payload...) + frames <- frame + }) + payload := []byte{1, 2, 3} + packet := jitterPacket(50, 1000, 0) + packet.Payload = payload + if err := buffer.Push(packet); err != nil { + t.Fatal(err) + } + payload[0] = 9 + got := readJitterFrames(t, frames, 1) + if !reflect.DeepEqual(got[0].Payload, []byte{1, 2, 3}) { + t.Fatalf("jitter buffer retained caller payload: %v", got[0].Payload) + } + buffer.Close() + if err := buffer.Push(packet); !errors.Is(err, ErrJitterBufferClosed) { + t.Fatalf("expected closed error, got %v", err) + } +} From 2687710e9564cbbe51d80a247b5abf1351870c57 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:54:49 -0300 Subject: [PATCH 117/266] fix(call): harden jitter rollover and timestamp tracking --- pkg/call/voip/media/jitter_buffer.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/call/voip/media/jitter_buffer.go b/pkg/call/voip/media/jitter_buffer.go index cc1e611e..9816076a 100644 --- a/pkg/call/voip/media/jitter_buffer.go +++ b/pkg/call/voip/media/jitter_buffer.go @@ -67,6 +67,8 @@ type JitterBuffer struct { started bool highestSequence uint64 nextSequence uint64 + lastTimestamp uint32 + hasTimestamp bool firstArrival time.Time consecutiveMissing int closed bool @@ -235,6 +237,8 @@ func (b *JitterBuffer) dequeue(now time.Time) (JitterFrame, bool) { } packet.payload = nil b.nextSequence++ + b.lastTimestamp = frame.Timestamp + b.hasTimestamp = true b.consecutiveMissing = 0 b.stats.Delivered++ return frame, true @@ -243,6 +247,8 @@ func (b *JitterBuffer) dequeue(now time.Time) (JitterFrame, bool) { sequence := uint16(b.nextSequence) timestamp := b.estimatedTimestampLocked() b.nextSequence++ + b.lastTimestamp = timestamp + b.hasTimestamp = true b.consecutiveMissing++ b.stats.Concealed++ frame := JitterFrame{SequenceNumber: sequence, Timestamp: timestamp, Concealed: true} @@ -269,6 +275,8 @@ func (b *JitterBuffer) resynchronizeLocked() { b.started = false b.highestSequence = 0 b.nextSequence = 0 + b.lastTimestamp = 0 + b.hasTimestamp = false b.firstArrival = time.Time{} return } @@ -277,7 +285,9 @@ func (b *JitterBuffer) resynchronizeLocked() { func (b *JitterBuffer) extendSequenceLocked(sequence uint16) uint64 { if !b.initialized { - return uint64(sequence) + // Start in epoch one so an out-of-order packet from the previous epoch can + // still be represented when sequence zero arrives before 65535. + return 1<<16 | uint64(sequence) } rollover := b.highestSequence >> 16 highestLow := uint16(b.highestSequence) @@ -304,12 +314,12 @@ func (b *JitterBuffer) minimumSequenceLocked() uint64 { } func (b *JitterBuffer) estimatedTimestampLocked() uint32 { + if b.hasTimestamp { + return b.lastTimestamp + uint32(MLowFrameSize) + } if next := b.packets[b.nextSequence+1]; next != nil { return next.timestamp - uint32(MLowFrameSize) } - if previous := b.packets[b.nextSequence-1]; previous != nil { - return previous.timestamp + uint32(MLowFrameSize) - } return 0 } From ab2724b2d18cd48555a1bcd71b49900312747729 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:56:00 -0300 Subject: [PATCH 118/266] chore(call): stage audio jitter integration --- tools/integrate_audio_jitter.py | 479 ++++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 tools/integrate_audio_jitter.py diff --git a/tools/integrate_audio_jitter.py b/tools/integrate_audio_jitter.py new file mode 100644 index 00000000..4e6a6a96 --- /dev/null +++ b/tools/integrate_audio_jitter.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +AUDIO = ROOT / "pkg/call/voip/media/audio_pipeline.go" +COORDINATOR = ROOT / "pkg/call/lifecycle/coordinator.go" +TEST = ROOT / "pkg/call/voip/media/audio_jitter_integration_test.go" +WORKFLOW = ROOT / ".github/workflows/integrate-audio-jitter.yml" + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise RuntimeError(f"{label}: expected one match, found {count}") + return text.replace(old, new, 1) + + +audio = AUDIO.read_text(encoding="utf-8") +audio = replace_once( + audio, + '''type AudioRegistryOptions struct { +\tCodecFactory CodecFactory +\tCodecOptions CodecOptions +\tSilenceTick time.Duration +\tSilenceAfter time.Duration +\tDisableSilence bool +}''', + '''type AudioRegistryOptions struct { +\tCodecFactory CodecFactory +\tCodecOptions CodecOptions +\tSilenceTick time.Duration +\tSilenceAfter time.Duration +\tDisableSilence bool +\tJitter JitterBufferOptions +}''', + "audio options", +) +audio = replace_once( + audio, + '''\t\tCodecFactory: NewMLowCodec, +\t\tCodecOptions: DefaultCodecOptions, +\t\tSilenceTick: 60 * time.Millisecond, +\t\tSilenceAfter: 120 * time.Millisecond, +''', + '''\t\tCodecFactory: NewMLowCodec, +\t\tCodecOptions: DefaultCodecOptions, +\t\tSilenceTick: 60 * time.Millisecond, +\t\tSilenceAfter: 120 * time.Millisecond, +\t\tJitter: DefaultJitterBufferOptions(), +''', + "default jitter options", +) +audio = replace_once( + audio, + '''\tinstanceID string +\tcallID string +\tcodec Codec +\tsender EncodedAudioSender + +\tencodeBuffer []float32''', + '''\tinstanceID string +\tcallID string +\tcodec Codec +\tsender EncodedAudioSender +\tonPCM func([]float32) +\tjitter *JitterBuffer + +\tencodeBuffer []float32''', + "audio session fields", +) +audio = replace_once( + audio, + '''func newAudioSession(instanceID, callID string, codec Codec, sender EncodedAudioSender, options AudioRegistryOptions) *audioSession { +\tsession := &audioSession{''', + '''func newAudioSession(instanceID, callID string, codec Codec, sender EncodedAudioSender, onPCM func([]float32), options AudioRegistryOptions) *audioSession { +\tsession := &audioSession{''', + "audio session signature", +) +audio = replace_once( + audio, + '''\t\tcodec: codec, +\t\tsender: sender, +\t\tencodeBuffer: make([]float32, codec.FrameSize()),''', + '''\t\tcodec: codec, +\t\tsender: sender, +\t\tonPCM: onPCM, +\t\tencodeBuffer: make([]float32, codec.FrameSize()),''', + "audio session initialization", +) +audio = replace_once( + audio, + '''\tif options.DisableSilence || options.SilenceTick <= 0 || options.SilenceAfter <= 0 { +\t\tclose(session.doneCh) +\t} else { +\t\tgo session.silenceLoop() +\t} +\treturn session +}''', + '''\tsession.jitter = NewJitterBuffer(&options.Jitter, session.handleJitterFrame) +\tif options.DisableSilence || options.SilenceTick <= 0 || options.SilenceAfter <= 0 { +\t\tclose(session.doneCh) +\t} else { +\t\tgo session.silenceLoop() +\t} +\treturn session +}''', + "jitter creation", +) +audio = replace_once( + audio, + '''func (s *audioSession) handleRTP(packet *RTPPacket) ([]float32, error) { +\tif s == nil || packet == nil || packet.Header == nil { +\t\treturn nil, ErrAudioSessionNotReady +\t} +\ts.mu.Lock() +\tdefer s.mu.Unlock() +\tif s.closed || s.codec == nil { +\t\treturn nil, ErrAudioSessionNotReady +\t} +\tdecoded, err := s.codec.Decode(packet.Payload) +\tif err != nil { +\t\treturn nil, fmt.Errorf("decode MLow payload: %w", err) +\t} +\treturn NormalizeFrame(decoded, s.codec.FrameSize()), nil +}''', + '''func (s *audioSession) handleRTP(packet *RTPPacket) error { +\tif s == nil || packet == nil || packet.Header == nil { +\t\treturn ErrAudioSessionNotReady +\t} +\ts.mu.Lock() +\tif s.closed || s.codec == nil || s.jitter == nil { +\t\ts.mu.Unlock() +\t\treturn ErrAudioSessionNotReady +\t} +\tjitter := s.jitter +\ts.mu.Unlock() +\treturn jitter.Push(packet) +} + +func (s *audioSession) handleJitterFrame(frame JitterFrame) { +\tif s == nil { +\t\treturn +\t} +\ts.mu.Lock() +\tif s.closed || s.codec == nil { +\t\ts.mu.Unlock() +\t\treturn +\t} +\tpayload := frame.Payload +\tif frame.Concealed { +\t\tpayload = nil +\t} +\tdecoded, err := s.codec.Decode(payload) +\tif err != nil { +\t\ts.mu.Unlock() +\t\treturn +\t} +\tpcm := NormalizeFrame(decoded, s.codec.FrameSize()) +\tcallback := s.onPCM +\ts.mu.Unlock() +\tdefer zeroFloat32(pcm) +\tif callback != nil { +\t\tcallback(append([]float32(nil), pcm...)) +\t} +} + +func (s *audioSession) jitterStats() JitterBufferStats { +\tif s == nil { +\t\treturn JitterBufferStats{} +\t} +\ts.mu.Lock() +\tjitter := s.jitter +\ts.mu.Unlock() +\tif jitter == nil { +\t\treturn JitterBufferStats{} +\t} +\treturn jitter.Stats() +}''', + "replace direct RTP decode", +) +audio = replace_once( + audio, + '''func (s *audioSession) close() { +\tif s == nil { +\t\treturn +\t} +\ts.stopOnce.Do(func() { close(s.stopCh) }) +\t<-s.doneCh + +\ts.mu.Lock() +\tif !s.closed { +\t\ts.closed = true +\t\tif s.codec != nil { +\t\t\ts.codec.Close() +\t\t} +\t\tzeroFloat32(s.encodeBuffer) +\t\ts.codec = nil +\t\ts.sender = nil +\t\ts.encodeBuffer = nil +\t\ts.encodePos = 0 +\t\ts.marker = false +\t\ts.lastCapture = time.Time{} +\t} +\ts.mu.Unlock() +}''', + '''func (s *audioSession) close() { +\tif s == nil { +\t\treturn +\t} +\ts.stopOnce.Do(func() { close(s.stopCh) }) +\t<-s.doneCh + +\ts.mu.Lock() +\tif s.closed { +\t\ts.mu.Unlock() +\t\treturn +\t} +\ts.closed = true +\tjitter := s.jitter +\ts.jitter = nil +\ts.mu.Unlock() + +\tif jitter != nil { +\t\tjitter.Close() +\t} + +\ts.mu.Lock() +\tif s.codec != nil { +\t\ts.codec.Close() +\t} +\tzeroFloat32(s.encodeBuffer) +\ts.codec = nil +\ts.sender = nil +\ts.onPCM = nil +\ts.encodeBuffer = nil +\ts.encodePos = 0 +\ts.marker = false +\ts.lastCapture = time.Time{} +\ts.mu.Unlock() +}''', + "jitter teardown", +) +audio = replace_once( + audio, + '''\tcandidate := newAudioSession(instanceID, callID, codec, r.sender, r.options) +''', + '''\tcandidate := newAudioSession(instanceID, callID, codec, r.sender, func(pcm []float32) { +\t\tr.emitPCM(instanceID, callID, pcm) +\t}, r.options) +''', + "audio session candidate", +) +audio = replace_once( + audio, + '''func (r *AudioRegistry) HandleRTP(instanceID, callID string, packet *RTPPacket) error { +\tsession, err := r.session(instanceID, callID, true) +\tif err != nil { +\t\treturn err +\t} +\tpcm, err := session.handleRTP(packet) +\tif err != nil { +\t\treturn err +\t} +\tdefer zeroFloat32(pcm) + +\tr.mu.RLock() +\tcallback := r.onPCM +\tr.mu.RUnlock() +\tif callback != nil { +\t\tcallback(instanceID, callID, append([]float32(nil), pcm...)) +\t} +\treturn nil +}''', + '''func (r *AudioRegistry) HandleRTP(instanceID, callID string, packet *RTPPacket) error { +\tsession, err := r.session(instanceID, callID, true) +\tif err != nil { +\t\treturn err +\t} +\terr = session.handleRTP(packet) +\tif errors.Is(err, ErrJitterDuplicatePacket) || errors.Is(err, ErrJitterLatePacket) { +\t\treturn nil +\t} +\treturn err +} + +func (r *AudioRegistry) emitPCM(instanceID, callID string, pcm []float32) { +\tr.mu.RLock() +\tcallback := r.onPCM +\tr.mu.RUnlock() +\tif callback != nil { +\t\tcallback(instanceID, callID, append([]float32(nil), pcm...)) +\t} +} + +func (r *AudioRegistry) JitterStats(instanceID, callID string) (JitterBufferStats, bool) { +\tsession, err := r.session(instanceID, callID, false) +\tif err != nil { +\t\treturn JitterBufferStats{}, false +\t} +\treturn session.jitterStats(), true +}''', + "registry jitter handling", +) +AUDIO.write_text(audio, encoding="utf-8") + +coordinator = COORDINATOR.read_text(encoding="utf-8") +coordinator = replace_once( + coordinator, + '''func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { +\tif c != nil { +\t\tc.audio.SetOnPCM(callback) +\t} +} +''', + '''func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { +\tif c != nil { +\t\tc.audio.SetOnPCM(callback) +\t} +} + +func (c *Coordinator) JitterStats(instanceID, callID string) (call_media.JitterBufferStats, bool) { +\tif c == nil { +\t\treturn call_media.JitterBufferStats{}, false +\t} +\treturn c.audio.JitterStats(instanceID, callID) +} +''', + "coordinator jitter stats", +) +COORDINATOR.write_text(coordinator, encoding="utf-8") + +TEST.write_text('''package media + +import ( + "sync" + "testing" + "time" +) + +type jitterIntegrationCodec struct { + mu sync.Mutex + decodedPayloads [][]byte + closed bool +} + +func (c *jitterIntegrationCodec) Encode(pcm []float32) ([]byte, error) { + return []byte{1}, nil +} + +func (c *jitterIntegrationCodec) Decode(payload []byte) ([]float32, error) { + c.mu.Lock() + c.decodedPayloads = append(c.decodedPayloads, append([]byte(nil), payload...)) + c.mu.Unlock() + value := float32(-1) + if len(payload) > 0 { + value = float32(payload[0]) + } + pcm := make([]float32, MLowFrameSize) + for i := range pcm { + pcm[i] = value + } + return pcm, nil +} + +func (c *jitterIntegrationCodec) FrameSize() int { return MLowFrameSize } +func (c *jitterIntegrationCodec) SampleRate() int { return MLowSampleRate } +func (c *jitterIntegrationCodec) Close() { + c.mu.Lock() + c.closed = true + c.mu.Unlock() +} + +func TestAudioRegistryReordersAndConcealsBeforePCM(t *testing.T) { + codec := &jitterIntegrationCodec{} + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + DisableSilence: true, + Jitter: JitterBufferOptions{ + FrameDuration: 3 * time.Millisecond, + InitialDelayPackets: 2, + MaxPackets: 8, + MaxConcealmentPackets: 2, + }, + } + registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) + defer registry.Close("instance") + + pcm := make(chan float32, 4) + registry.SetOnPCM(func(_, _ string, samples []float32) { + pcm <- samples[0] + }) + + if err := registry.HandleRTP("instance", "call", jitterPacket(102, 2920, 3)); err != nil { + t.Fatal(err) + } + if err := registry.HandleRTP("instance", "call", jitterPacket(100, 1000, 1)); err != nil { + t.Fatal(err) + } + + want := []float32{1, -1, 3} + for index, expected := range want { + select { + case actual := <-pcm: + if actual != expected { + t.Fatalf("frame %d decoded %v, want %v", index, actual, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for PCM frame %d", index) + } + } + + stats, ok := registry.JitterStats("instance", "call") + if !ok || stats.Delivered != 2 || stats.Concealed != 1 { + t.Fatalf("unexpected jitter stats: ok=%v stats=%+v", ok, stats) + } +} + +func TestAudioRegistryIgnoresDuplicateAndLatePackets(t *testing.T) { + codec := &jitterIntegrationCodec{} + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + DisableSilence: true, + Jitter: JitterBufferOptions{ + FrameDuration: 3 * time.Millisecond, + InitialDelayPackets: 1, + MaxPackets: 8, + MaxConcealmentPackets: 1, + }, + } + registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) + defer registry.Close("instance") + + packet := jitterPacket(200, 1000, 1) + if err := registry.HandleRTP("instance", "call", packet); err != nil { + t.Fatal(err) + } + if err := registry.HandleRTP("instance", "call", packet); err != nil { + t.Fatalf("duplicate should be ignored, got %v", err) + } + time.Sleep(15 * time.Millisecond) + if err := registry.HandleRTP("instance", "call", packet); err != nil { + t.Fatalf("late packet should be ignored, got %v", err) + } + + stats, ok := registry.JitterStats("instance", "call") + if !ok || stats.Duplicate != 1 || stats.Late != 1 { + t.Fatalf("unexpected jitter stats: ok=%v stats=%+v", ok, stats) + } +} + +func TestAudioRegistryCloseStopsPlayoutBeforeCodecClose(t *testing.T) { + codec := &jitterIntegrationCodec{} + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + DisableSilence: true, + Jitter: JitterBufferOptions{ + FrameDuration: time.Millisecond, + InitialDelayPackets: 1, + MaxPackets: 8, + MaxConcealmentPackets: 2, + }, + } + registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) + if err := registry.HandleRTP("instance", "call", jitterPacket(300, 1000, 1)); err != nil { + t.Fatal(err) + } + registry.Remove("instance", "call") + + codec.mu.Lock() + closed := codec.closed + codec.mu.Unlock() + if !closed { + t.Fatal("codec was not closed after jitter playout stopped") + } +} +''', encoding="utf-8") + +Path(__file__).unlink() +WORKFLOW.unlink() From a0edc1abfaf2c269953df87f9a8f0104c7bbcbc7 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:56:10 -0300 Subject: [PATCH 119/266] chore(call): run audio jitter integration --- .github/workflows/integrate-audio-jitter.yml | 58 ++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/integrate-audio-jitter.yml diff --git a/.github/workflows/integrate-audio-jitter.yml b/.github/workflows/integrate-audio-jitter.yml new file mode 100644 index 00000000..9abcaf4c --- /dev/null +++ b/.github/workflows/integrate-audio-jitter.yml @@ -0,0 +1,58 @@ +name: Integrate audio jitter + +on: + push: + branches: + - dev/astracalls-integration + paths: + - tools/integrate_audio_jitter.py + - .github/workflows/integrate-audio-jitter.yml + +permissions: + contents: write + +jobs: + integrate: + runs-on: ubuntu-24.04 + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: dev/astracalls-integration + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Apply jitter integration + run: python3 tools/integrate_audio_jitter.py + + - name: Format changed Go files + run: | + gofmt -w \ + pkg/call/voip/media/audio_pipeline.go \ + pkg/call/voip/media/audio_jitter_integration_test.go \ + pkg/call/voip/media/jitter_buffer.go \ + pkg/call/voip/media/jitter_buffer_test.go \ + pkg/call/lifecycle/coordinator.go + + - name: Test default call build + run: go test -race ./pkg/call/... + + - name: Test experimental Pion build + run: go test -race -tags=voip_pion ./pkg/call/... + + - name: Commit jitter integration + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + git commit -m "feat(call): integrate jitter buffer with PCM playout" + git push origin HEAD:dev/astracalls-integration From ae636f3ce3560b844c5c8462870039b782508573 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:57:56 -0300 Subject: [PATCH 120/266] feat(call): route decoded audio through jitter playout --- pkg/call/voip/media/audio_pipeline.go | 137 ++++++++++++++++++++------ 1 file changed, 109 insertions(+), 28 deletions(-) diff --git a/pkg/call/voip/media/audio_pipeline.go b/pkg/call/voip/media/audio_pipeline.go index 718562c5..5dad4e13 100644 --- a/pkg/call/voip/media/audio_pipeline.go +++ b/pkg/call/voip/media/audio_pipeline.go @@ -26,6 +26,7 @@ type AudioRegistryOptions struct { SilenceTick time.Duration SilenceAfter time.Duration DisableSilence bool + Jitter JitterBufferOptions } func DefaultAudioRegistryOptions() AudioRegistryOptions { @@ -34,6 +35,7 @@ func DefaultAudioRegistryOptions() AudioRegistryOptions { CodecOptions: DefaultCodecOptions, SilenceTick: 60 * time.Millisecond, SilenceAfter: 120 * time.Millisecond, + Jitter: DefaultJitterBufferOptions(), } } @@ -44,6 +46,8 @@ type audioSession struct { callID string codec Codec sender EncodedAudioSender + onPCM func([]float32) + jitter *JitterBuffer encodeBuffer []float32 encodePos int @@ -58,12 +62,13 @@ type audioSession struct { stopOnce sync.Once } -func newAudioSession(instanceID, callID string, codec Codec, sender EncodedAudioSender, options AudioRegistryOptions) *audioSession { +func newAudioSession(instanceID, callID string, codec Codec, sender EncodedAudioSender, onPCM func([]float32), options AudioRegistryOptions) *audioSession { session := &audioSession{ instanceID: instanceID, callID: callID, codec: codec, sender: sender, + onPCM: onPCM, encodeBuffer: make([]float32, codec.FrameSize()), marker: true, lastCapture: time.Now(), @@ -72,6 +77,7 @@ func newAudioSession(instanceID, callID string, codec Codec, sender EncodedAudio stopCh: make(chan struct{}), doneCh: make(chan struct{}), } + session.jitter = NewJitterBuffer(&options.Jitter, session.handleJitterFrame) if options.DisableSilence || options.SilenceTick <= 0 || options.SilenceAfter <= 0 { close(session.doneCh) } else { @@ -119,20 +125,58 @@ func (s *audioSession) feedPCM(pcm []float32) error { return nil } -func (s *audioSession) handleRTP(packet *RTPPacket) ([]float32, error) { +func (s *audioSession) handleRTP(packet *RTPPacket) error { if s == nil || packet == nil || packet.Header == nil { - return nil, ErrAudioSessionNotReady + return ErrAudioSessionNotReady + } + s.mu.Lock() + if s.closed || s.codec == nil || s.jitter == nil { + s.mu.Unlock() + return ErrAudioSessionNotReady + } + jitter := s.jitter + s.mu.Unlock() + return jitter.Push(packet) +} + +func (s *audioSession) handleJitterFrame(frame JitterFrame) { + if s == nil { + return } s.mu.Lock() - defer s.mu.Unlock() if s.closed || s.codec == nil { - return nil, ErrAudioSessionNotReady + s.mu.Unlock() + return + } + payload := frame.Payload + if frame.Concealed { + payload = nil } - decoded, err := s.codec.Decode(packet.Payload) + decoded, err := s.codec.Decode(payload) if err != nil { - return nil, fmt.Errorf("decode MLow payload: %w", err) + s.mu.Unlock() + return + } + pcm := NormalizeFrame(decoded, s.codec.FrameSize()) + callback := s.onPCM + s.mu.Unlock() + defer zeroFloat32(pcm) + if callback != nil { + callback(append([]float32(nil), pcm...)) } - return NormalizeFrame(decoded, s.codec.FrameSize()), nil +} + +func (s *audioSession) jitterStats() JitterBufferStats { + if s == nil { + return JitterBufferStats{} + } + s.mu.Lock() + jitter := s.jitter + s.mu.Unlock() + if jitter == nil { + return JitterBufferStats{} + } + return jitter.Stats() } func (s *audioSession) encodeAndSendLocked(frame []float32) error { @@ -186,19 +230,31 @@ func (s *audioSession) close() { <-s.doneCh s.mu.Lock() - if !s.closed { - s.closed = true - if s.codec != nil { - s.codec.Close() - } - zeroFloat32(s.encodeBuffer) - s.codec = nil - s.sender = nil - s.encodeBuffer = nil - s.encodePos = 0 - s.marker = false - s.lastCapture = time.Time{} + if s.closed { + s.mu.Unlock() + return } + s.closed = true + jitter := s.jitter + s.jitter = nil + s.mu.Unlock() + + if jitter != nil { + jitter.Close() + } + + s.mu.Lock() + if s.codec != nil { + s.codec.Close() + } + zeroFloat32(s.encodeBuffer) + s.codec = nil + s.sender = nil + s.onPCM = nil + s.encodeBuffer = nil + s.encodePos = 0 + s.marker = false + s.lastCapture = time.Time{} s.mu.Unlock() } @@ -216,8 +272,9 @@ func sanitizePCMSample(sample float32) float32 { return sample } -// AudioRegistry owns one codec and PCM accumulator per call. It is independent -// from HTTP and device APIs so browser, native and test bridges can reuse it. +// AudioRegistry owns one codec, jitter buffer and PCM accumulator per call. It +// is independent from HTTP and device APIs so browser, native and test bridges +// can reuse it. type AudioRegistry struct { mu sync.RWMutex @@ -240,6 +297,19 @@ func NewAudioRegistry(sender EncodedAudioSender, options *AudioRegistryOptions) if resolved.SilenceAfter == 0 { resolved.SilenceAfter = 120 * time.Millisecond } + jitterDefaults := DefaultJitterBufferOptions() + if resolved.Jitter.FrameDuration <= 0 { + resolved.Jitter.FrameDuration = jitterDefaults.FrameDuration + } + if resolved.Jitter.InitialDelayPackets <= 0 { + resolved.Jitter.InitialDelayPackets = jitterDefaults.InitialDelayPackets + } + if resolved.Jitter.MaxPackets <= 0 { + resolved.Jitter.MaxPackets = jitterDefaults.MaxPackets + } + if resolved.Jitter.MaxConcealmentPackets <= 0 { + resolved.Jitter.MaxConcealmentPackets = jitterDefaults.MaxConcealmentPackets + } } return &AudioRegistry{ options: resolved, @@ -276,7 +346,9 @@ func (r *AudioRegistry) Prepare(instanceID, callID string) error { if err != nil { return fmt.Errorf("create MLow codec: %w", err) } - candidate := newAudioSession(instanceID, callID, codec, r.sender, r.options) + candidate := newAudioSession(instanceID, callID, codec, r.sender, func(pcm []float32) { + r.emitPCM(instanceID, callID, pcm) + }, r.options) r.mu.Lock() calls := r.sessions[instanceID] @@ -307,19 +379,28 @@ func (r *AudioRegistry) HandleRTP(instanceID, callID string, packet *RTPPacket) if err != nil { return err } - pcm, err := session.handleRTP(packet) - if err != nil { - return err + err = session.handleRTP(packet) + if errors.Is(err, ErrJitterDuplicatePacket) || errors.Is(err, ErrJitterLatePacket) { + return nil } - defer zeroFloat32(pcm) + return err +} +func (r *AudioRegistry) emitPCM(instanceID, callID string, pcm []float32) { r.mu.RLock() callback := r.onPCM r.mu.RUnlock() if callback != nil { callback(instanceID, callID, append([]float32(nil), pcm...)) } - return nil +} + +func (r *AudioRegistry) JitterStats(instanceID, callID string) (JitterBufferStats, bool) { + session, err := r.session(instanceID, callID, false) + if err != nil { + return JitterBufferStats{}, false + } + return session.jitterStats(), true } func (r *AudioRegistry) session(instanceID, callID string, lazy bool) (*audioSession, error) { From 7b6615492fae8eeadd472eda8496b604c6c8b4f5 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:58:14 -0300 Subject: [PATCH 121/266] test(call): cover jittered PCM playout --- .../media/audio_jitter_integration_test.go | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 pkg/call/voip/media/audio_jitter_integration_test.go diff --git a/pkg/call/voip/media/audio_jitter_integration_test.go b/pkg/call/voip/media/audio_jitter_integration_test.go new file mode 100644 index 00000000..b77fdd64 --- /dev/null +++ b/pkg/call/voip/media/audio_jitter_integration_test.go @@ -0,0 +1,144 @@ +package media + +import ( + "sync" + "testing" + "time" +) + +type jitterIntegrationCodec struct { + mu sync.Mutex + decodedPayloads [][]byte + closed bool +} + +func (c *jitterIntegrationCodec) Encode(pcm []float32) ([]byte, error) { + return []byte{1}, nil +} + +func (c *jitterIntegrationCodec) Decode(payload []byte) ([]float32, error) { + c.mu.Lock() + c.decodedPayloads = append(c.decodedPayloads, append([]byte(nil), payload...)) + c.mu.Unlock() + value := float32(-1) + if len(payload) > 0 { + value = float32(payload[0]) + } + pcm := make([]float32, MLowFrameSize) + for index := range pcm { + pcm[index] = value + } + return pcm, nil +} + +func (c *jitterIntegrationCodec) FrameSize() int { return MLowFrameSize } +func (c *jitterIntegrationCodec) SampleRate() int { return MLowSampleRate } +func (c *jitterIntegrationCodec) Close() { + c.mu.Lock() + c.closed = true + c.mu.Unlock() +} + +func TestAudioRegistryReordersAndConcealsBeforePCM(t *testing.T) { + codec := &jitterIntegrationCodec{} + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + DisableSilence: true, + Jitter: JitterBufferOptions{ + FrameDuration: 3 * time.Millisecond, + InitialDelayPackets: 2, + MaxPackets: 8, + MaxConcealmentPackets: 2, + }, + } + registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) + defer registry.Close("instance") + + pcm := make(chan float32, 4) + registry.SetOnPCM(func(_, _ string, samples []float32) { + pcm <- samples[0] + }) + + if err := registry.HandleRTP("instance", "call", jitterPacket(102, 2920, 3)); err != nil { + t.Fatal(err) + } + if err := registry.HandleRTP("instance", "call", jitterPacket(100, 1000, 1)); err != nil { + t.Fatal(err) + } + + want := []float32{1, -1, 3} + for index, expected := range want { + select { + case actual := <-pcm: + if actual != expected { + t.Fatalf("frame %d decoded %v, want %v", index, actual, expected) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for PCM frame %d", index) + } + } + + stats, ok := registry.JitterStats("instance", "call") + if !ok || stats.Delivered != 2 || stats.Concealed != 1 { + t.Fatalf("unexpected jitter stats: ok=%v stats=%+v", ok, stats) + } +} + +func TestAudioRegistryIgnoresDuplicateAndLatePackets(t *testing.T) { + codec := &jitterIntegrationCodec{} + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + DisableSilence: true, + Jitter: JitterBufferOptions{ + FrameDuration: 3 * time.Millisecond, + InitialDelayPackets: 1, + MaxPackets: 8, + MaxConcealmentPackets: 1, + }, + } + registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) + defer registry.Close("instance") + + packet := jitterPacket(200, 1000, 1) + if err := registry.HandleRTP("instance", "call", packet); err != nil { + t.Fatal(err) + } + if err := registry.HandleRTP("instance", "call", packet); err != nil { + t.Fatalf("duplicate should be ignored, got %v", err) + } + time.Sleep(15 * time.Millisecond) + if err := registry.HandleRTP("instance", "call", packet); err != nil { + t.Fatalf("late packet should be ignored, got %v", err) + } + + stats, ok := registry.JitterStats("instance", "call") + if !ok || stats.Duplicate != 1 || stats.Late != 1 { + t.Fatalf("unexpected jitter stats: ok=%v stats=%+v", ok, stats) + } +} + +func TestAudioRegistryCloseStopsPlayoutBeforeCodecClose(t *testing.T) { + codec := &jitterIntegrationCodec{} + options := &AudioRegistryOptions{ + CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, + DisableSilence: true, + Jitter: JitterBufferOptions{ + FrameDuration: time.Millisecond, + InitialDelayPackets: 1, + MaxPackets: 8, + MaxConcealmentPackets: 2, + }, + } + registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) + if err := registry.HandleRTP("instance", "call", jitterPacket(300, 1000, 1)); err != nil { + t.Fatal(err) + } + registry.Remove("instance", "call") + + codec.mu.Lock() + closed := codec.closed + codec.mu.Unlock() + if !closed { + t.Fatal("codec was not closed after jitter playout stopped") + } +} From 78e5f17ae519391a8d8007439a0a1bbdefb298e6 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:58:42 -0300 Subject: [PATCH 122/266] chore(call): remove completed jitter migration helper --- tools/integrate_audio_jitter.py | 479 -------------------------------- 1 file changed, 479 deletions(-) delete mode 100644 tools/integrate_audio_jitter.py diff --git a/tools/integrate_audio_jitter.py b/tools/integrate_audio_jitter.py deleted file mode 100644 index 4e6a6a96..00000000 --- a/tools/integrate_audio_jitter.py +++ /dev/null @@ -1,479 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -AUDIO = ROOT / "pkg/call/voip/media/audio_pipeline.go" -COORDINATOR = ROOT / "pkg/call/lifecycle/coordinator.go" -TEST = ROOT / "pkg/call/voip/media/audio_jitter_integration_test.go" -WORKFLOW = ROOT / ".github/workflows/integrate-audio-jitter.yml" - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise RuntimeError(f"{label}: expected one match, found {count}") - return text.replace(old, new, 1) - - -audio = AUDIO.read_text(encoding="utf-8") -audio = replace_once( - audio, - '''type AudioRegistryOptions struct { -\tCodecFactory CodecFactory -\tCodecOptions CodecOptions -\tSilenceTick time.Duration -\tSilenceAfter time.Duration -\tDisableSilence bool -}''', - '''type AudioRegistryOptions struct { -\tCodecFactory CodecFactory -\tCodecOptions CodecOptions -\tSilenceTick time.Duration -\tSilenceAfter time.Duration -\tDisableSilence bool -\tJitter JitterBufferOptions -}''', - "audio options", -) -audio = replace_once( - audio, - '''\t\tCodecFactory: NewMLowCodec, -\t\tCodecOptions: DefaultCodecOptions, -\t\tSilenceTick: 60 * time.Millisecond, -\t\tSilenceAfter: 120 * time.Millisecond, -''', - '''\t\tCodecFactory: NewMLowCodec, -\t\tCodecOptions: DefaultCodecOptions, -\t\tSilenceTick: 60 * time.Millisecond, -\t\tSilenceAfter: 120 * time.Millisecond, -\t\tJitter: DefaultJitterBufferOptions(), -''', - "default jitter options", -) -audio = replace_once( - audio, - '''\tinstanceID string -\tcallID string -\tcodec Codec -\tsender EncodedAudioSender - -\tencodeBuffer []float32''', - '''\tinstanceID string -\tcallID string -\tcodec Codec -\tsender EncodedAudioSender -\tonPCM func([]float32) -\tjitter *JitterBuffer - -\tencodeBuffer []float32''', - "audio session fields", -) -audio = replace_once( - audio, - '''func newAudioSession(instanceID, callID string, codec Codec, sender EncodedAudioSender, options AudioRegistryOptions) *audioSession { -\tsession := &audioSession{''', - '''func newAudioSession(instanceID, callID string, codec Codec, sender EncodedAudioSender, onPCM func([]float32), options AudioRegistryOptions) *audioSession { -\tsession := &audioSession{''', - "audio session signature", -) -audio = replace_once( - audio, - '''\t\tcodec: codec, -\t\tsender: sender, -\t\tencodeBuffer: make([]float32, codec.FrameSize()),''', - '''\t\tcodec: codec, -\t\tsender: sender, -\t\tonPCM: onPCM, -\t\tencodeBuffer: make([]float32, codec.FrameSize()),''', - "audio session initialization", -) -audio = replace_once( - audio, - '''\tif options.DisableSilence || options.SilenceTick <= 0 || options.SilenceAfter <= 0 { -\t\tclose(session.doneCh) -\t} else { -\t\tgo session.silenceLoop() -\t} -\treturn session -}''', - '''\tsession.jitter = NewJitterBuffer(&options.Jitter, session.handleJitterFrame) -\tif options.DisableSilence || options.SilenceTick <= 0 || options.SilenceAfter <= 0 { -\t\tclose(session.doneCh) -\t} else { -\t\tgo session.silenceLoop() -\t} -\treturn session -}''', - "jitter creation", -) -audio = replace_once( - audio, - '''func (s *audioSession) handleRTP(packet *RTPPacket) ([]float32, error) { -\tif s == nil || packet == nil || packet.Header == nil { -\t\treturn nil, ErrAudioSessionNotReady -\t} -\ts.mu.Lock() -\tdefer s.mu.Unlock() -\tif s.closed || s.codec == nil { -\t\treturn nil, ErrAudioSessionNotReady -\t} -\tdecoded, err := s.codec.Decode(packet.Payload) -\tif err != nil { -\t\treturn nil, fmt.Errorf("decode MLow payload: %w", err) -\t} -\treturn NormalizeFrame(decoded, s.codec.FrameSize()), nil -}''', - '''func (s *audioSession) handleRTP(packet *RTPPacket) error { -\tif s == nil || packet == nil || packet.Header == nil { -\t\treturn ErrAudioSessionNotReady -\t} -\ts.mu.Lock() -\tif s.closed || s.codec == nil || s.jitter == nil { -\t\ts.mu.Unlock() -\t\treturn ErrAudioSessionNotReady -\t} -\tjitter := s.jitter -\ts.mu.Unlock() -\treturn jitter.Push(packet) -} - -func (s *audioSession) handleJitterFrame(frame JitterFrame) { -\tif s == nil { -\t\treturn -\t} -\ts.mu.Lock() -\tif s.closed || s.codec == nil { -\t\ts.mu.Unlock() -\t\treturn -\t} -\tpayload := frame.Payload -\tif frame.Concealed { -\t\tpayload = nil -\t} -\tdecoded, err := s.codec.Decode(payload) -\tif err != nil { -\t\ts.mu.Unlock() -\t\treturn -\t} -\tpcm := NormalizeFrame(decoded, s.codec.FrameSize()) -\tcallback := s.onPCM -\ts.mu.Unlock() -\tdefer zeroFloat32(pcm) -\tif callback != nil { -\t\tcallback(append([]float32(nil), pcm...)) -\t} -} - -func (s *audioSession) jitterStats() JitterBufferStats { -\tif s == nil { -\t\treturn JitterBufferStats{} -\t} -\ts.mu.Lock() -\tjitter := s.jitter -\ts.mu.Unlock() -\tif jitter == nil { -\t\treturn JitterBufferStats{} -\t} -\treturn jitter.Stats() -}''', - "replace direct RTP decode", -) -audio = replace_once( - audio, - '''func (s *audioSession) close() { -\tif s == nil { -\t\treturn -\t} -\ts.stopOnce.Do(func() { close(s.stopCh) }) -\t<-s.doneCh - -\ts.mu.Lock() -\tif !s.closed { -\t\ts.closed = true -\t\tif s.codec != nil { -\t\t\ts.codec.Close() -\t\t} -\t\tzeroFloat32(s.encodeBuffer) -\t\ts.codec = nil -\t\ts.sender = nil -\t\ts.encodeBuffer = nil -\t\ts.encodePos = 0 -\t\ts.marker = false -\t\ts.lastCapture = time.Time{} -\t} -\ts.mu.Unlock() -}''', - '''func (s *audioSession) close() { -\tif s == nil { -\t\treturn -\t} -\ts.stopOnce.Do(func() { close(s.stopCh) }) -\t<-s.doneCh - -\ts.mu.Lock() -\tif s.closed { -\t\ts.mu.Unlock() -\t\treturn -\t} -\ts.closed = true -\tjitter := s.jitter -\ts.jitter = nil -\ts.mu.Unlock() - -\tif jitter != nil { -\t\tjitter.Close() -\t} - -\ts.mu.Lock() -\tif s.codec != nil { -\t\ts.codec.Close() -\t} -\tzeroFloat32(s.encodeBuffer) -\ts.codec = nil -\ts.sender = nil -\ts.onPCM = nil -\ts.encodeBuffer = nil -\ts.encodePos = 0 -\ts.marker = false -\ts.lastCapture = time.Time{} -\ts.mu.Unlock() -}''', - "jitter teardown", -) -audio = replace_once( - audio, - '''\tcandidate := newAudioSession(instanceID, callID, codec, r.sender, r.options) -''', - '''\tcandidate := newAudioSession(instanceID, callID, codec, r.sender, func(pcm []float32) { -\t\tr.emitPCM(instanceID, callID, pcm) -\t}, r.options) -''', - "audio session candidate", -) -audio = replace_once( - audio, - '''func (r *AudioRegistry) HandleRTP(instanceID, callID string, packet *RTPPacket) error { -\tsession, err := r.session(instanceID, callID, true) -\tif err != nil { -\t\treturn err -\t} -\tpcm, err := session.handleRTP(packet) -\tif err != nil { -\t\treturn err -\t} -\tdefer zeroFloat32(pcm) - -\tr.mu.RLock() -\tcallback := r.onPCM -\tr.mu.RUnlock() -\tif callback != nil { -\t\tcallback(instanceID, callID, append([]float32(nil), pcm...)) -\t} -\treturn nil -}''', - '''func (r *AudioRegistry) HandleRTP(instanceID, callID string, packet *RTPPacket) error { -\tsession, err := r.session(instanceID, callID, true) -\tif err != nil { -\t\treturn err -\t} -\terr = session.handleRTP(packet) -\tif errors.Is(err, ErrJitterDuplicatePacket) || errors.Is(err, ErrJitterLatePacket) { -\t\treturn nil -\t} -\treturn err -} - -func (r *AudioRegistry) emitPCM(instanceID, callID string, pcm []float32) { -\tr.mu.RLock() -\tcallback := r.onPCM -\tr.mu.RUnlock() -\tif callback != nil { -\t\tcallback(instanceID, callID, append([]float32(nil), pcm...)) -\t} -} - -func (r *AudioRegistry) JitterStats(instanceID, callID string) (JitterBufferStats, bool) { -\tsession, err := r.session(instanceID, callID, false) -\tif err != nil { -\t\treturn JitterBufferStats{}, false -\t} -\treturn session.jitterStats(), true -}''', - "registry jitter handling", -) -AUDIO.write_text(audio, encoding="utf-8") - -coordinator = COORDINATOR.read_text(encoding="utf-8") -coordinator = replace_once( - coordinator, - '''func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { -\tif c != nil { -\t\tc.audio.SetOnPCM(callback) -\t} -} -''', - '''func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { -\tif c != nil { -\t\tc.audio.SetOnPCM(callback) -\t} -} - -func (c *Coordinator) JitterStats(instanceID, callID string) (call_media.JitterBufferStats, bool) { -\tif c == nil { -\t\treturn call_media.JitterBufferStats{}, false -\t} -\treturn c.audio.JitterStats(instanceID, callID) -} -''', - "coordinator jitter stats", -) -COORDINATOR.write_text(coordinator, encoding="utf-8") - -TEST.write_text('''package media - -import ( - "sync" - "testing" - "time" -) - -type jitterIntegrationCodec struct { - mu sync.Mutex - decodedPayloads [][]byte - closed bool -} - -func (c *jitterIntegrationCodec) Encode(pcm []float32) ([]byte, error) { - return []byte{1}, nil -} - -func (c *jitterIntegrationCodec) Decode(payload []byte) ([]float32, error) { - c.mu.Lock() - c.decodedPayloads = append(c.decodedPayloads, append([]byte(nil), payload...)) - c.mu.Unlock() - value := float32(-1) - if len(payload) > 0 { - value = float32(payload[0]) - } - pcm := make([]float32, MLowFrameSize) - for i := range pcm { - pcm[i] = value - } - return pcm, nil -} - -func (c *jitterIntegrationCodec) FrameSize() int { return MLowFrameSize } -func (c *jitterIntegrationCodec) SampleRate() int { return MLowSampleRate } -func (c *jitterIntegrationCodec) Close() { - c.mu.Lock() - c.closed = true - c.mu.Unlock() -} - -func TestAudioRegistryReordersAndConcealsBeforePCM(t *testing.T) { - codec := &jitterIntegrationCodec{} - options := &AudioRegistryOptions{ - CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, - DisableSilence: true, - Jitter: JitterBufferOptions{ - FrameDuration: 3 * time.Millisecond, - InitialDelayPackets: 2, - MaxPackets: 8, - MaxConcealmentPackets: 2, - }, - } - registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) - defer registry.Close("instance") - - pcm := make(chan float32, 4) - registry.SetOnPCM(func(_, _ string, samples []float32) { - pcm <- samples[0] - }) - - if err := registry.HandleRTP("instance", "call", jitterPacket(102, 2920, 3)); err != nil { - t.Fatal(err) - } - if err := registry.HandleRTP("instance", "call", jitterPacket(100, 1000, 1)); err != nil { - t.Fatal(err) - } - - want := []float32{1, -1, 3} - for index, expected := range want { - select { - case actual := <-pcm: - if actual != expected { - t.Fatalf("frame %d decoded %v, want %v", index, actual, expected) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for PCM frame %d", index) - } - } - - stats, ok := registry.JitterStats("instance", "call") - if !ok || stats.Delivered != 2 || stats.Concealed != 1 { - t.Fatalf("unexpected jitter stats: ok=%v stats=%+v", ok, stats) - } -} - -func TestAudioRegistryIgnoresDuplicateAndLatePackets(t *testing.T) { - codec := &jitterIntegrationCodec{} - options := &AudioRegistryOptions{ - CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, - DisableSilence: true, - Jitter: JitterBufferOptions{ - FrameDuration: 3 * time.Millisecond, - InitialDelayPackets: 1, - MaxPackets: 8, - MaxConcealmentPackets: 1, - }, - } - registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) - defer registry.Close("instance") - - packet := jitterPacket(200, 1000, 1) - if err := registry.HandleRTP("instance", "call", packet); err != nil { - t.Fatal(err) - } - if err := registry.HandleRTP("instance", "call", packet); err != nil { - t.Fatalf("duplicate should be ignored, got %v", err) - } - time.Sleep(15 * time.Millisecond) - if err := registry.HandleRTP("instance", "call", packet); err != nil { - t.Fatalf("late packet should be ignored, got %v", err) - } - - stats, ok := registry.JitterStats("instance", "call") - if !ok || stats.Duplicate != 1 || stats.Late != 1 { - t.Fatalf("unexpected jitter stats: ok=%v stats=%+v", ok, stats) - } -} - -func TestAudioRegistryCloseStopsPlayoutBeforeCodecClose(t *testing.T) { - codec := &jitterIntegrationCodec{} - options := &AudioRegistryOptions{ - CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil }, - DisableSilence: true, - Jitter: JitterBufferOptions{ - FrameDuration: time.Millisecond, - InitialDelayPackets: 1, - MaxPackets: 8, - MaxConcealmentPackets: 2, - }, - } - registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options) - if err := registry.HandleRTP("instance", "call", jitterPacket(300, 1000, 1)); err != nil { - t.Fatal(err) - } - registry.Remove("instance", "call") - - codec.mu.Lock() - closed := codec.closed - codec.mu.Unlock() - if !closed { - t.Fatal("codec was not closed after jitter playout stopped") - } -} -''', encoding="utf-8") - -Path(__file__).unlink() -WORKFLOW.unlink() From bd7e37a900e8efcdeb123a9444f01232db1ba15d Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:58:49 -0300 Subject: [PATCH 123/266] chore(call): remove completed jitter migration workflow --- .github/workflows/integrate-audio-jitter.yml | 58 -------------------- 1 file changed, 58 deletions(-) delete mode 100644 .github/workflows/integrate-audio-jitter.yml diff --git a/.github/workflows/integrate-audio-jitter.yml b/.github/workflows/integrate-audio-jitter.yml deleted file mode 100644 index 9abcaf4c..00000000 --- a/.github/workflows/integrate-audio-jitter.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Integrate audio jitter - -on: - push: - branches: - - dev/astracalls-integration - paths: - - tools/integrate_audio_jitter.py - - .github/workflows/integrate-audio-jitter.yml - -permissions: - contents: write - -jobs: - integrate: - runs-on: ubuntu-24.04 - steps: - - name: Checkout integration branch - uses: actions/checkout@v4 - with: - ref: dev/astracalls-integration - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Apply jitter integration - run: python3 tools/integrate_audio_jitter.py - - - name: Format changed Go files - run: | - gofmt -w \ - pkg/call/voip/media/audio_pipeline.go \ - pkg/call/voip/media/audio_jitter_integration_test.go \ - pkg/call/voip/media/jitter_buffer.go \ - pkg/call/voip/media/jitter_buffer_test.go \ - pkg/call/lifecycle/coordinator.go - - - name: Test default call build - run: go test -race ./pkg/call/... - - - name: Test experimental Pion build - run: go test -race -tags=voip_pion ./pkg/call/... - - - name: Commit jitter integration - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - if git diff --cached --quiet; then - echo "No changes to commit" - exit 0 - fi - git commit -m "feat(call): integrate jitter buffer with PCM playout" - git push origin HEAD:dev/astracalls-integration From 06985434a5e7dd2e09475a8e375b1a4527243e3f Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:59:46 -0300 Subject: [PATCH 124/266] fix(call): conceal only confirmed RTP gaps --- pkg/call/voip/media/jitter_buffer.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/call/voip/media/jitter_buffer.go b/pkg/call/voip/media/jitter_buffer.go index 9816076a..bf7c5f7b 100644 --- a/pkg/call/voip/media/jitter_buffer.go +++ b/pkg/call/voip/media/jitter_buffer.go @@ -244,6 +244,13 @@ func (b *JitterBuffer) dequeue(now time.Time) (JitterFrame, bool) { return frame, true } + // Only conceal a gap when a later packet proves that an expected sequence + // number is missing. At end-of-stream there is no future packet, so playout + // pauses instead of fabricating trailing audio or blocking teardown callbacks. + if len(b.packets) == 0 || b.highestSequence < b.nextSequence { + return JitterFrame{}, false + } + sequence := uint16(b.nextSequence) timestamp := b.estimatedTimestampLocked() b.nextSequence++ From 56cd3a3983965b0fbd52c6732a9a4c42105a419e Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:00:21 -0300 Subject: [PATCH 125/266] test(call): align bounded PLC with confirmed gaps --- pkg/call/voip/media/jitter_buffer_test.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/pkg/call/voip/media/jitter_buffer_test.go b/pkg/call/voip/media/jitter_buffer_test.go index 83bc2482..6fc806c1 100644 --- a/pkg/call/voip/media/jitter_buffer_test.go +++ b/pkg/call/voip/media/jitter_buffer_test.go @@ -156,7 +156,6 @@ func TestJitterBufferRejectsDuplicateLateAndOverflow(t *testing.T) { func TestJitterBufferStopsAfterBoundedConcealment(t *testing.T) { options := testJitterOptions() - options.InitialDelayPackets = 1 frames := make(chan JitterFrame, 8) buffer := NewJitterBuffer(&options, func(frame JitterFrame) { frames <- frame }) defer buffer.Close() @@ -164,9 +163,18 @@ func TestJitterBufferStopsAfterBoundedConcealment(t *testing.T) { if err := buffer.Push(jitterPacket(40, 1000, 1)); err != nil { t.Fatal(err) } - got := readJitterFrames(t, frames, 3) - if got[0].Concealed || !got[1].Concealed || !got[2].Concealed { - t.Fatalf("unexpected concealment sequence: %+v", got) + if err := buffer.Push(jitterPacket(45, 5800, 5)); err != nil { + t.Fatal(err) + } + got := readJitterFrames(t, frames, 4) + if got[0].SequenceNumber != 40 || got[0].Concealed { + t.Fatalf("unexpected first frame: %+v", got[0]) + } + if got[1].SequenceNumber != 41 || !got[1].Concealed || got[2].SequenceNumber != 42 || !got[2].Concealed { + t.Fatalf("concealment was not bounded: %+v", got) + } + if got[3].SequenceNumber != 45 || got[3].Concealed { + t.Fatalf("buffer did not resynchronize to future packet: %+v", got[3]) } select { @@ -174,6 +182,10 @@ func TestJitterBufferStopsAfterBoundedConcealment(t *testing.T) { t.Fatalf("unbounded concealment produced extra frame: %+v", extra) case <-time.After(25 * time.Millisecond): } + stats := buffer.Stats() + if stats.Delivered != 2 || stats.Concealed != 2 { + t.Fatalf("unexpected bounded-concealment stats: %+v", stats) + } } func TestJitterBufferCopiesAndWipesOwnedPayload(t *testing.T) { From 05248e506c3dda3d57bd18254c24789094f2fcf0 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:01:57 -0300 Subject: [PATCH 126/266] docs(call): document jitter buffering and PLC --- docs/wiki/guias-api/api-calls-experimental.md | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md index 3f7baf0e..7f98c57f 100644 --- a/docs/wiki/guias-api/api-calls-experimental.md +++ b/docs/wiki/guias-api/api-calls-experimental.md @@ -2,7 +2,7 @@ Esta branch adiciona a integração experimental WaCalls/AstraCalls ao Evolution Go. -> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante `voip_pion` negocia DataChannels com os relays, processa RTP/SRTP autenticado e possui codec MLow com entrada e saída PCM internas. Ainda não há áudio audível para o usuário porque microfone, reprodução e ponte WebRTC não foram conectados. Não use em produção. +> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante `voip_pion` negocia DataChannels com os relays, processa RTP/SRTP autenticado e possui codec MLow, jitter buffer e entrada/saída PCM internas. Ainda não há áudio audível para o usuário porque microfone, reprodução e ponte WebRTC não foram conectados. Não use em produção. Todas as rotas usam a autenticação normal da instância do Evolution. @@ -12,7 +12,7 @@ Todas as rotas usam a autenticação normal da instância do Evolution. GET /call/status ``` -Os monitores são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels, sessões RTP/SRTP, codecs e material privado são removidos antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento. +Os monitores são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels, sessões RTP/SRTP, jitter buffers, codecs e material privado são removidos antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento. Exemplo de resposta: @@ -24,7 +24,7 @@ Exemplo de resposta: } ``` -Chaves de chamada, JIDs internos de dispositivos, chaves SRTP, tokens de relay e buffers PCM não fazem parte dessa resposta. Eles ficam somente em memória e são apagados quando a chamada termina, é rejeitada ou a sessão é desconectada. +Chaves de chamada, JIDs internos de dispositivos, chaves SRTP, tokens de relay, pacotes enfileirados e buffers PCM não fazem parte dessa resposta. Eles ficam somente em memória e são apagados quando a chamada termina, é rejeitada ou a sessão é desconectada. Instâncias configuradas com rejeição automática continuam rastreando o estado público da chamada. Elas não descriptografam ofertas recebidas nem enviam `preaccept`, mas continuam podendo iniciar chamadas e armazenar sua negociação privada de saída. @@ -80,7 +80,7 @@ O runtime descriptografa a chave recebida usando a sessão Signal já autenticad } ``` -`CallAccept` não marca a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que o DataChannel abre e as sessões RTP/SRTP e MLow são criadas com sucesso. +`CallAccept` não marca a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que o DataChannel abre e as sessões RTP/SRTP, jitter e MLow são criadas com sucesso. Se a preparação criptográfica do evento ainda não terminou, a API retorna um erro informando que a chamada ainda não está pronta para aceite. @@ -91,7 +91,7 @@ DELETE /call/{callId} apikey: INSTANCE_TOKEN ``` -A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove chave, candidatos, DataChannels, contextos RTP/SRTP, codec, buffers PCM e demais dados privados da chamada. +A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove chave, candidatos, DataChannels, contextos RTP/SRTP, jitter buffer, codec, buffers PCM e demais dados privados da chamada. ## Rejeitar uma chamada recebida @@ -172,6 +172,29 @@ O caminho de pacotes inclui: - sessão independente por `callId`; - limpeza sincronizada durante término, rejeição, logout ou reconexão. +## Jitter buffer e perda de pacotes + +Cada chamada possui um jitter buffer independente antes do decoder MLow. A configuração padrão atual é fixa: + +- duração de frame de 60 ms; +- atraso inicial de dois pacotes, aproximadamente 120 ms; +- limite de 64 pacotes enfileirados; +- no máximo cinco frames consecutivos de concealment por lacuna. + +O buffer: + +- usa número de sequência estendido para ordenar pacotes durante o rollover `65535 → 0`; +- aceita pacotes fora de ordem que ainda não perderam o prazo de reprodução; +- rejeita duplicatas, pacotes atrasados e estouro do limite sem derrubar a chamada; +- mantém contadores internos de recebidos, entregues, ocultados, duplicados, atrasados e descartados por limite; +- gera PLC chamando `Decode(nil)` somente quando um pacote futuro confirma uma lacuna; +- limita o PLC consecutivo e depois sincroniza novamente no próximo pacote disponível; +- não fabrica áudio no final do fluxo quando não existe pacote futuro; +- copia e apaga os payloads privados que mantém na fila; +- encerra o relógio de playout antes de destruir o codec. + +Esta primeira versão não adapta automaticamente o atraso à variação observada da rede. A adaptação dinâmica será feita depois da validação com relays reais. + ## Codec MLow e PCM O codec MLow em Go puro foi portado da revisão MIT fixada `edeb31f0427aba896639db503153b777a405eccf` do WaCalls. O runtime não depende de CGO ou de uma instalação externa de `libopus` para essa etapa. @@ -186,10 +209,11 @@ O pipeline interno agora: - codifica MLow e envia o payload pelo RTP/SRTP existente; - preserva o marker RTP no primeiro frame transmitido; - envia frames de silêncio quando a captura fica inativa; -- decodifica payloads recebidos para blocos PCM de 960 amostras; +- entrega RTP recebido ao jitter buffer antes da decodificação; +- decodifica payloads ordenados ou PLC para blocos PCM de 960 amostras; - entrega uma cópia do PCM a um callback interno; - serializa encoder e decoder por chamada; -- espera envios em andamento antes do teardown. +- espera envios e playout em andamento antes do teardown. As fronteiras internas disponíveis no `Coordinator` são: @@ -227,7 +251,7 @@ go test -race -tags=voip_pion ./pkg/call/... - sem reprodução em alto-falante; - sem ponte WebRTC para navegador; - sem resampling automático para fontes que não sejam mono/16 kHz; -- sem jitter buffer adaptativo ou concealment coordenado por perda de RTP; +- jitter buffer ainda estático, sem ajuste adaptativo por atraso e variação da rede; - sem endpoint ou protocolo público de streaming de áudio; - a conexão real com um relay WhatsApp ainda precisa ser validada de ponta a ponta com uma conta conectada; - as chaves e sessões ficam somente em memória e não sobrevivem a reinícios; From 66e5a674ce3874c0c451494f70184769f59010df Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:13:24 -0300 Subject: [PATCH 127/266] chore(call): stage browser WebRTC PCM bridge migration --- tools/integrate_browser_webrtc.py | 1462 +++++++++++++++++++++++++++++ 1 file changed, 1462 insertions(+) create mode 100644 tools/integrate_browser_webrtc.py diff --git a/tools/integrate_browser_webrtc.py b/tools/integrate_browser_webrtc.py new file mode 100644 index 00000000..e51a75d1 --- /dev/null +++ b/tools/integrate_browser_webrtc.py @@ -0,0 +1,1462 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def write(path: str, content: str) -> None: + target = ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + +def replace_once(path: str, old: str, new: str, label: str) -> None: + target = ROOT / path + text = target.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise RuntimeError(f"{label}: expected one match in {path}, found {count}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + +write("pkg/call/voip/browser/types.go", r'''package browser + +import ( + "context" + "errors" + "time" +) + +const ( + DataChannelLabel = "evolution-call-pcm" + DataChannelProtocol = "evcall.pcm.v1" + PCMSampleRate = 16000 + PCMChannels = 1 + PCMFrameSamples = 960 +) + +var ( + ErrWebRTCDisabled = errors.New("browser WebRTC bridge requires the voip_pion build tag") + ErrInvalidOffer = errors.New("invalid WebRTC SDP offer") + ErrSessionNotFound = errors.New("browser WebRTC session not found") + ErrSessionLimit = errors.New("browser WebRTC session limit reached") + ErrInvalidPCMMessage = errors.New("invalid browser PCM message") +) + +type SDPDescription struct { + Type string `json:"type" binding:"required"` + SDP string `json:"sdp" binding:"required"` +} + +type CreateRequest struct { + Offer SDPDescription `json:"offer" binding:"required"` +} + +type ProtocolInfo struct { + DataChannel string `json:"dataChannel"` + Protocol string `json:"protocol"` + Format string `json:"format"` + SampleRate int `json:"sampleRate"` + Channels int `json:"channels"` + FrameSamples int `json:"frameSamples"` +} + +type CreateResponse struct { + SessionID string `json:"sessionId"` + Answer SDPDescription `json:"answer"` + Audio ProtocolInfo `json:"audio"` +} + +type SessionState string + +const ( + SessionStateConnecting SessionState = "connecting" + SessionStateOpen SessionState = "open" + SessionStateClosed SessionState = "closed" + SessionStateFailed SessionState = "failed" +) + +type SessionInfo struct { + SessionID string `json:"sessionId"` + CallID string `json:"callId"` + State SessionState `json:"state"` + ChannelOpen bool `json:"channelOpen"` + CreatedAt time.Time `json:"createdAt"` + InputFrames uint64 `json:"inputFrames"` + OutputFrames uint64 `json:"outputFrames"` + DroppedFrames uint64 `json:"droppedFrames"` +} + +type PCMFeeder func(instanceID, callID string, pcm []float32) error + +type Manager interface { + Create(ctx context.Context, instanceID, callID string, request CreateRequest) (CreateResponse, error) + Sessions(instanceID, callID string) ([]SessionInfo, error) + CloseSession(instanceID, callID, sessionID string) error + CloseCall(instanceID, callID string) + CloseInstance(instanceID string) + HandlePCM(instanceID, callID string, pcm []float32) +} + +func DefaultProtocolInfo() ProtocolInfo { + return ProtocolInfo{ + DataChannel: DataChannelLabel, + Protocol: DataChannelProtocol, + Format: "f32le", + SampleRate: PCMSampleRate, + Channels: PCMChannels, + FrameSamples: PCMFrameSamples, + } +} +''') + +write("pkg/call/voip/browser/frame.go", r'''package browser + +import ( + "encoding/binary" + "fmt" + "math" +) + +const ( + pcmHeaderSize = 16 + pcmVersion = 1 + pcmKind = 1 + maxPCMSamples = PCMFrameSamples * 4 +) + +var pcmMagic = [4]byte{'E', 'V', 'P', 'C'} + +func EncodePCMFrame(pcm []float32) ([]byte, error) { + if len(pcm) == 0 || len(pcm) > maxPCMSamples { + return nil, fmt.Errorf("%w: sample count %d", ErrInvalidPCMMessage, len(pcm)) + } + output := make([]byte, pcmHeaderSize+len(pcm)*4) + copy(output[:4], pcmMagic[:]) + output[4] = pcmVersion + output[5] = pcmKind + binary.LittleEndian.PutUint16(output[6:8], 0) + binary.LittleEndian.PutUint32(output[8:12], PCMSampleRate) + binary.LittleEndian.PutUint32(output[12:16], uint32(len(pcm))) + offset := pcmHeaderSize + for _, sample := range pcm { + binary.LittleEndian.PutUint32(output[offset:offset+4], math.Float32bits(sample)) + offset += 4 + } + return output, nil +} + +func DecodePCMFrame(frame []byte) ([]float32, error) { + if len(frame) < pcmHeaderSize { + return nil, fmt.Errorf("%w: frame has %d bytes", ErrInvalidPCMMessage, len(frame)) + } + if string(frame[:4]) != string(pcmMagic[:]) || frame[4] != pcmVersion || frame[5] != pcmKind { + return nil, fmt.Errorf("%w: unsupported framing", ErrInvalidPCMMessage) + } + if binary.LittleEndian.Uint16(frame[6:8]) != 0 { + return nil, fmt.Errorf("%w: unsupported flags", ErrInvalidPCMMessage) + } + if binary.LittleEndian.Uint32(frame[8:12]) != PCMSampleRate { + return nil, fmt.Errorf("%w: sample rate must be %d", ErrInvalidPCMMessage, PCMSampleRate) + } + sampleCount := int(binary.LittleEndian.Uint32(frame[12:16])) + if sampleCount <= 0 || sampleCount > maxPCMSamples { + return nil, fmt.Errorf("%w: sample count %d", ErrInvalidPCMMessage, sampleCount) + } + expected := pcmHeaderSize + sampleCount*4 + if len(frame) != expected { + return nil, fmt.Errorf("%w: frame has %d bytes, want %d", ErrInvalidPCMMessage, len(frame), expected) + } + pcm := make([]float32, sampleCount) + offset := pcmHeaderSize + for index := range pcm { + pcm[index] = math.Float32frombits(binary.LittleEndian.Uint32(frame[offset : offset+4])) + offset += 4 + } + return pcm, nil +} + +func zeroPCM(values []float32) { + for index := range values { + values[index] = 0 + } +} + +func zeroFrame(value []byte) { + for index := range value { + value[index] = 0 + } +} +''') + +write("pkg/call/voip/browser/manager_default.go", r'''//go:build !voip_pion + +package browser + +import "context" + +type disabledManager struct{} + +func NewManager(PCMFeeder) Manager { + return &disabledManager{} +} + +func (*disabledManager) Create(context.Context, string, string, CreateRequest) (CreateResponse, error) { + return CreateResponse{}, ErrWebRTCDisabled +} + +func (*disabledManager) Sessions(string, string) ([]SessionInfo, error) { + return nil, ErrWebRTCDisabled +} + +func (*disabledManager) CloseSession(string, string, string) error { + return ErrWebRTCDisabled +} + +func (*disabledManager) CloseCall(string, string) {} +func (*disabledManager) CloseInstance(string) {} +func (*disabledManager) HandlePCM(string, string, []float32) {} +''') + +write("pkg/call/voip/browser/manager_pion.go", r'''//go:build voip_pion + +package browser + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/pion/webrtc/v4" +) + +const ( + maxSessionsPerCall = 4 + maxOfferBytes = 256 * 1024 + maxBufferedAmount = 512 * 1024 + mediaQueueDepth = 8 +) + +type pionManager struct { + mu sync.RWMutex + feeder PCMFeeder + sessions map[string]map[string]map[string]*pionSession +} + +type pionSession struct { + manager *pionManager + instanceID string + callID string + id string + createdAt time.Time + pc *webrtc.PeerConnection + + mu sync.RWMutex + channel *webrtc.DataChannel + state SessionState + inputFrames uint64 + outputFrames uint64 + droppedFrames uint64 + + incoming chan []float32 + outgoing chan []byte + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewManager(feeder PCMFeeder) Manager { + return &pionManager{ + feeder: feeder, + sessions: make(map[string]map[string]map[string]*pionSession), + } +} + +func (m *pionManager) Create(ctx context.Context, instanceID, callID string, request CreateRequest) (CreateResponse, error) { + if m == nil || instanceID == "" || callID == "" { + return CreateResponse{}, ErrInvalidOffer + } + offerType := strings.ToLower(strings.TrimSpace(request.Offer.Type)) + if offerType != "offer" || request.Offer.SDP == "" || len(request.Offer.SDP) > maxOfferBytes { + return CreateResponse{}, ErrInvalidOffer + } + + m.mu.Lock() + calls := m.sessions[instanceID] + if calls == nil { + calls = make(map[string]map[string]*pionSession) + m.sessions[instanceID] = calls + } + callSessions := calls[callID] + if callSessions == nil { + callSessions = make(map[string]*pionSession) + calls[callID] = callSessions + } + if len(callSessions) >= maxSessionsPerCall { + m.mu.Unlock() + return CreateResponse{}, ErrSessionLimit + } + sessionID := uuid.NewString() + m.mu.Unlock() + + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + return CreateResponse{}, fmt.Errorf("create browser peer connection: %w", err) + } + session := &pionSession{ + manager: m, + instanceID: instanceID, + callID: callID, + id: sessionID, + createdAt: time.Now().UTC(), + pc: pc, + state: SessionStateConnecting, + incoming: make(chan []float32, mediaQueueDepth), + outgoing: make(chan []byte, mediaQueueDepth), + stopCh: make(chan struct{}), + } + session.wg.Add(2) + go session.inputLoop() + go session.outputLoop() + + m.mu.Lock() + calls = m.sessions[instanceID] + if calls == nil { + calls = make(map[string]map[string]*pionSession) + m.sessions[instanceID] = calls + } + callSessions = calls[callID] + if callSessions == nil { + callSessions = make(map[string]*pionSession) + calls[callID] = callSessions + } + if len(callSessions) >= maxSessionsPerCall { + m.mu.Unlock() + session.close() + return CreateResponse{}, ErrSessionLimit + } + callSessions[sessionID] = session + m.mu.Unlock() + + fail := func(cause error) (CreateResponse, error) { + _ = m.CloseSession(instanceID, callID, sessionID) + return CreateResponse{}, cause + } + + pc.OnDataChannel(func(channel *webrtc.DataChannel) { + session.attachDataChannel(channel) + }) + pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + switch state { + case webrtc.PeerConnectionStateFailed: + session.setState(SessionStateFailed) + go func() { _ = m.CloseSession(instanceID, callID, sessionID) }() + case webrtc.PeerConnectionStateClosed: + go func() { _ = m.CloseSession(instanceID, callID, sessionID) }() + } + }) + + remote := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: request.Offer.SDP} + if err = pc.SetRemoteDescription(remote); err != nil { + return fail(fmt.Errorf("set browser remote description: %w", err)) + } + answer, err := pc.CreateAnswer(nil) + if err != nil { + return fail(fmt.Errorf("create browser SDP answer: %w", err)) + } + gatheringComplete := webrtc.GatheringCompletePromise(pc) + if err = pc.SetLocalDescription(answer); err != nil { + return fail(fmt.Errorf("set browser local description: %w", err)) + } + select { + case <-gatheringComplete: + case <-ctx.Done(): + return fail(fmt.Errorf("gather browser ICE candidates: %w", ctx.Err())) + } + local := pc.LocalDescription() + if local == nil || local.SDP == "" { + return fail(fmt.Errorf("create browser SDP answer: empty local description")) + } + + return CreateResponse{ + SessionID: sessionID, + Answer: SDPDescription{Type: "answer", SDP: local.SDP}, + Audio: DefaultProtocolInfo(), + }, nil +} + +func (m *pionManager) Sessions(instanceID, callID string) ([]SessionInfo, error) { + if m == nil { + return nil, ErrSessionNotFound + } + sessions := m.snapshot(instanceID, callID) + result := make([]SessionInfo, 0, len(sessions)) + for _, session := range sessions { + result = append(result, session.info()) + } + sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt.Before(result[j].CreatedAt) }) + return result, nil +} + +func (m *pionManager) CloseSession(instanceID, callID, sessionID string) error { + if m == nil || sessionID == "" { + return ErrSessionNotFound + } + m.mu.Lock() + calls := m.sessions[instanceID] + callSessions := calls[callID] + session := callSessions[sessionID] + if session != nil { + delete(callSessions, sessionID) + if len(callSessions) == 0 { + delete(calls, callID) + } + if len(calls) == 0 { + delete(m.sessions, instanceID) + } + } + m.mu.Unlock() + if session == nil { + return ErrSessionNotFound + } + session.close() + return nil +} + +func (m *pionManager) CloseCall(instanceID, callID string) { + for _, session := range m.takeCall(instanceID, callID) { + session.close() + } +} + +func (m *pionManager) CloseInstance(instanceID string) { + if m == nil { + return + } + m.mu.Lock() + calls := m.sessions[instanceID] + delete(m.sessions, instanceID) + m.mu.Unlock() + for _, callSessions := range calls { + for _, session := range callSessions { + session.close() + } + } +} + +func (m *pionManager) HandlePCM(instanceID, callID string, pcm []float32) { + if len(pcm) == 0 { + return + } + frame, err := EncodePCMFrame(pcm) + if err != nil { + return + } + defer zeroFrame(frame) + for _, session := range m.snapshot(instanceID, callID) { + session.enqueueOutgoing(append([]byte(nil), frame...)) + } +} + +func (m *pionManager) snapshot(instanceID, callID string) []*pionSession { + if m == nil { + return nil + } + m.mu.RLock() + callSessions := m.sessions[instanceID][callID] + result := make([]*pionSession, 0, len(callSessions)) + for _, session := range callSessions { + result = append(result, session) + } + m.mu.RUnlock() + return result +} + +func (m *pionManager) takeCall(instanceID, callID string) []*pionSession { + if m == nil { + return nil + } + m.mu.Lock() + calls := m.sessions[instanceID] + callSessions := calls[callID] + delete(calls, callID) + if len(calls) == 0 { + delete(m.sessions, instanceID) + } + result := make([]*pionSession, 0, len(callSessions)) + for _, session := range callSessions { + result = append(result, session) + } + m.mu.Unlock() + return result +} + +func (s *pionSession) attachDataChannel(channel *webrtc.DataChannel) { + if channel == nil || channel.Label() != DataChannelLabel { + if channel != nil { + _ = channel.Close() + } + s.incrementDropped() + return + } + if protocol := channel.Protocol(); protocol != "" && protocol != DataChannelProtocol { + _ = channel.Close() + s.incrementDropped() + return + } + + s.mu.Lock() + if s.channel != nil || s.state == SessionStateClosed || s.state == SessionStateFailed { + s.mu.Unlock() + _ = channel.Close() + return + } + s.channel = channel + s.mu.Unlock() + + channel.SetBufferedAmountLowThreshold(maxBufferedAmount / 2) + channel.OnOpen(func() { s.setState(SessionStateOpen) }) + channel.OnClose(func() { + go func() { _ = s.manager.CloseSession(s.instanceID, s.callID, s.id) }() + }) + channel.OnMessage(func(message webrtc.DataChannelMessage) { + if message.IsString { + s.incrementDropped() + return + } + pcm, err := DecodePCMFrame(message.Data) + if err != nil { + s.incrementDropped() + return + } + s.enqueueIncoming(pcm) + }) +} + +func (s *pionSession) enqueueIncoming(pcm []float32) { + select { + case <-s.stopCh: + zeroPCM(pcm) + case s.incoming <- pcm: + default: + zeroPCM(pcm) + s.incrementDropped() + } +} + +func (s *pionSession) enqueueOutgoing(frame []byte) { + if !s.isOpen() { + zeroFrame(frame) + s.incrementDropped() + return + } + select { + case <-s.stopCh: + zeroFrame(frame) + case s.outgoing <- frame: + default: + zeroFrame(frame) + s.incrementDropped() + } +} + +func (s *pionSession) inputLoop() { + defer s.wg.Done() + for { + select { + case <-s.stopCh: + return + case pcm := <-s.incoming: + if s.manager.feeder != nil { + if err := s.manager.feeder(s.instanceID, s.callID, pcm); err != nil { + s.incrementDropped() + } else { + s.mu.Lock() + s.inputFrames++ + s.mu.Unlock() + } + } else { + s.incrementDropped() + } + zeroPCM(pcm) + } + } +} + +func (s *pionSession) outputLoop() { + defer s.wg.Done() + for { + select { + case <-s.stopCh: + return + case frame := <-s.outgoing: + s.sendFrame(frame) + zeroFrame(frame) + } + } +} + +func (s *pionSession) sendFrame(frame []byte) { + s.mu.RLock() + channel := s.channel + open := s.state == SessionStateOpen && channel != nil + s.mu.RUnlock() + if !open || channel.BufferedAmount() > maxBufferedAmount { + s.incrementDropped() + return + } + if err := channel.Send(frame); err != nil { + s.incrementDropped() + return + } + s.mu.Lock() + s.outputFrames++ + s.mu.Unlock() +} + +func (s *pionSession) setState(state SessionState) { + s.mu.Lock() + if s.state != SessionStateClosed { + s.state = state + } + s.mu.Unlock() +} + +func (s *pionSession) isOpen() bool { + s.mu.RLock() + open := s.state == SessionStateOpen && s.channel != nil + s.mu.RUnlock() + return open +} + +func (s *pionSession) incrementDropped() { + s.mu.Lock() + s.droppedFrames++ + s.mu.Unlock() +} + +func (s *pionSession) info() SessionInfo { + s.mu.RLock() + info := SessionInfo{ + SessionID: s.id, + CallID: s.callID, + State: s.state, + ChannelOpen: s.state == SessionStateOpen && s.channel != nil, + CreatedAt: s.createdAt, + InputFrames: s.inputFrames, + OutputFrames: s.outputFrames, + DroppedFrames: s.droppedFrames, + } + s.mu.RUnlock() + return info +} + +func (s *pionSession) close() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + s.mu.Lock() + s.state = SessionStateClosed + channel := s.channel + s.channel = nil + pc := s.pc + s.pc = nil + s.mu.Unlock() + if channel != nil { + _ = channel.Close() + } + if pc != nil { + _ = pc.Close() + } + s.wg.Wait() + for { + select { + case pcm := <-s.incoming: + zeroPCM(pcm) + default: + goto outgoing + } + } + outgoing: + for { + select { + case frame := <-s.outgoing: + zeroFrame(frame) + default: + return + } + } + }) +} + +var _ Manager = (*pionManager)(nil) +var _ = errors.Is +''') + +write("pkg/call/voip/browser/frame_test.go", r'''package browser + +import ( + "errors" + "math" + "testing" +) + +func TestPCMFrameRoundTrip(t *testing.T) { + input := []float32{-1, -0.25, 0, 0.5, 1} + frame, err := EncodePCMFrame(input) + if err != nil { + t.Fatal(err) + } + output, err := DecodePCMFrame(frame) + if err != nil { + t.Fatal(err) + } + if len(output) != len(input) { + t.Fatalf("decoded %d samples, want %d", len(output), len(input)) + } + for index := range input { + if math.Float32bits(output[index]) != math.Float32bits(input[index]) { + t.Fatalf("sample %d=%v, want %v", index, output[index], input[index]) + } + } +} + +func TestPCMFrameRejectsMalformedInput(t *testing.T) { + frame, err := EncodePCMFrame(make([]float32, PCMFrameSamples)) + if err != nil { + t.Fatal(err) + } + cases := [][]byte{ + nil, + frame[:10], + append([]byte(nil), frame[:len(frame)-1]...), + append([]byte("BAD!"), frame[4:]...), + } + for _, value := range cases { + if _, err = DecodePCMFrame(value); !errors.Is(err, ErrInvalidPCMMessage) { + t.Fatalf("expected invalid PCM error, got %v", err) + } + } +} + +func TestPCMFrameLimitsSamples(t *testing.T) { + if _, err := EncodePCMFrame(nil); !errors.Is(err, ErrInvalidPCMMessage) { + t.Fatalf("expected empty frame error, got %v", err) + } + if _, err := EncodePCMFrame(make([]float32, maxPCMSamples+1)); !errors.Is(err, ErrInvalidPCMMessage) { + t.Fatalf("expected oversized frame error, got %v", err) + } +} +''') + +write("pkg/call/voip/browser/manager_default_test.go", r'''//go:build !voip_pion + +package browser + +import ( + "context" + "errors" + "testing" +) + +func TestDefaultManagerIsDisabled(t *testing.T) { + manager := NewManager(nil) + _, err := manager.Create(context.Background(), "instance", "call", CreateRequest{}) + if !errors.Is(err, ErrWebRTCDisabled) { + t.Fatalf("expected disabled error, got %v", err) + } +} +''') + +write("pkg/call/voip/browser/manager_pion_test.go", r'''//go:build voip_pion + +package browser + +import ( + "context" + "testing" + "time" + + "github.com/pion/webrtc/v4" +) + +func TestPionManagerBridgesPCMOverDataChannel(t *testing.T) { + fed := make(chan []float32, 2) + manager := NewManager(func(_, _ string, pcm []float32) error { + fed <- append([]float32(nil), pcm...) + return nil + }) + defer manager.CloseInstance("instance") + + client, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + protocol := DataChannelProtocol + channel, err := client.CreateDataChannel(DataChannelLabel, &webrtc.DataChannelInit{Protocol: &protocol}) + if err != nil { + t.Fatal(err) + } + opened := make(chan struct{}) + received := make(chan []float32, 2) + channel.OnOpen(func() { close(opened) }) + channel.OnMessage(func(message webrtc.DataChannelMessage) { + pcm, decodeErr := DecodePCMFrame(message.Data) + if decodeErr != nil { + t.Errorf("decode server PCM: %v", decodeErr) + return + } + received <- pcm + }) + + offer, err := client.CreateOffer(nil) + if err != nil { + t.Fatal(err) + } + gather := webrtc.GatheringCompletePromise(client) + if err = client.SetLocalDescription(offer); err != nil { + t.Fatal(err) + } + select { + case <-gather: + case <-time.After(10 * time.Second): + t.Fatal("client ICE gathering timed out") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + response, err := manager.Create(ctx, "instance", "call", CreateRequest{Offer: SDPDescription{ + Type: "offer", + SDP: client.LocalDescription().SDP, + }}) + if err != nil { + t.Fatal(err) + } + if err = client.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: response.Answer.SDP}); err != nil { + t.Fatal(err) + } + select { + case <-opened: + case <-time.After(10 * time.Second): + t.Fatal("PCM data channel did not open") + } + + browserPCM := make([]float32, PCMFrameSamples) + browserPCM[0] = 0.25 + frame, err := EncodePCMFrame(browserPCM) + if err != nil { + t.Fatal(err) + } + if err = channel.Send(frame); err != nil { + t.Fatal(err) + } + select { + case got := <-fed: + if len(got) != PCMFrameSamples || got[0] != 0.25 { + t.Fatalf("unexpected fed PCM: len=%d first=%v", len(got), got[0]) + } + case <-time.After(10 * time.Second): + t.Fatal("server did not receive browser PCM") + } + + serverPCM := make([]float32, PCMFrameSamples) + serverPCM[0] = -0.5 + manager.HandlePCM("instance", "call", serverPCM) + select { + case got := <-received: + if len(got) != PCMFrameSamples || got[0] != -0.5 { + t.Fatalf("unexpected browser PCM: len=%d first=%v", len(got), got[0]) + } + case <-time.After(10 * time.Second): + t.Fatal("browser did not receive server PCM") + } + + sessions, err := manager.Sessions("instance", "call") + if err != nil || len(sessions) != 1 || !sessions[0].ChannelOpen { + t.Fatalf("unexpected sessions: %+v err=%v", sessions, err) + } + if err = manager.CloseSession("instance", "call", response.SessionID); err != nil { + t.Fatal(err) + } + sessions, err = manager.Sessions("instance", "call") + if err != nil || len(sessions) != 0 { + t.Fatalf("session was not removed: %+v err=%v", sessions, err) + } +} +''') + +# Coordinator: PCM fanout and media cleanup hooks. +replace_once( + "pkg/call/lifecycle/coordinator.go", + '''\taudio *call_media.AudioRegistry +\tonRTP func(instanceID, callID string, packet *call_media.RTPPacket) + +\tincomingEnabled map[string]bool +''', + '''\taudio *call_media.AudioRegistry +\tonRTP func(instanceID, callID string, packet *call_media.RTPPacket) +\tonPCM func(instanceID, callID string, pcm []float32) +\tbrowserPCM func(instanceID, callID string, pcm []float32) +\tonCallMediaCleanup func(instanceID, callID string) +\tonInstanceMediaCleanup func(instanceID string) + +\tincomingEnabled map[string]bool +''', + "coordinator callbacks", +) +replace_once( + "pkg/call/lifecycle/coordinator.go", + '''\tcoordinator.audio = call_media.NewAudioRegistry(func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { +\t\treturn coordinator.SendOpus(instanceID, callID, payload, durationSamples, marker) +\t}, nil) +\tcoordinator.relays = call_media.NewRelayRegistry(incoming, nil, nil) +''', + '''\tcoordinator.audio = call_media.NewAudioRegistry(func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { +\t\treturn coordinator.SendOpus(instanceID, callID, payload, durationSamples, marker) +\t}, nil) +\tcoordinator.audio.SetOnPCM(coordinator.dispatchPCM) +\tcoordinator.relays = call_media.NewRelayRegistry(incoming, nil, nil) +\tcoordinator.relays.SetOnRemoved(func(instanceID, callID string) { +\t\tcoordinator.audio.Remove(instanceID, callID) +\t\tcoordinator.packets.Remove(instanceID, callID) +\t\tcoordinator.notifyCallMediaCleanup(instanceID, callID) +\t}) +\tcoordinator.relays.SetOnCleanup(func(instanceID string) { +\t\tcoordinator.audio.Close(instanceID) +\t\tcoordinator.packets.Close(instanceID) +\t\tcoordinator.notifyInstanceMediaCleanup(instanceID) +\t}) +''', + "coordinator relay cleanup", +) +replace_once( + "pkg/call/lifecycle/coordinator.go", + '''\tc.relays.Close(instanceID) +\tc.audio.Close(instanceID) +\tc.packets.Close(instanceID) +''', + '''\tc.relays.Close(instanceID) +\tc.audio.Close(instanceID) +\tc.packets.Close(instanceID) +\tc.notifyInstanceMediaCleanup(instanceID) +''', + "coordinator detach cleanup", +) +replace_once( + "pkg/call/lifecycle/coordinator.go", + '''\tc.relays.Remove(instanceID, callID) +\tc.audio.Remove(instanceID, callID) +\tc.packets.Remove(instanceID, callID) +\treturn nil +''', + '''\tc.relays.Remove(instanceID, callID) +\tc.audio.Remove(instanceID, callID) +\tc.packets.Remove(instanceID, callID) +\tc.notifyCallMediaCleanup(instanceID, callID) +\treturn nil +''', + "coordinator terminate cleanup", +) +replace_once( + "pkg/call/lifecycle/coordinator.go", + '''// SetOnPCM registers the internal decoded-audio sink used by a future WebRTC, +// native playback or test bridge. The callback receives an owned PCM copy. +func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { +\tif c != nil { +\t\tc.audio.SetOnPCM(callback) +\t} +} +''', + '''// SetOnPCM registers an optional external decoded-audio observer. Browser +// media keeps a separate internal sink so neither callback replaces the other. +func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { +\tif c == nil { +\t\treturn +\t} +\tc.mu.Lock() +\tc.onPCM = callback +\tc.mu.Unlock() +} + +func (c *Coordinator) SetBrowserPCM(callback func(instanceID, callID string, pcm []float32)) { +\tif c == nil { +\t\treturn +\t} +\tc.mu.Lock() +\tc.browserPCM = callback +\tc.mu.Unlock() +} + +func (c *Coordinator) SetMediaCleanupHooks(onCall func(instanceID, callID string), onInstance func(instanceID string)) { +\tif c == nil { +\t\treturn +\t} +\tc.mu.Lock() +\tc.onCallMediaCleanup = onCall +\tc.onInstanceMediaCleanup = onInstance +\tc.mu.Unlock() +} + +func (c *Coordinator) dispatchPCM(instanceID, callID string, pcm []float32) { +\tc.mu.RLock() +\tbrowserCallback := c.browserPCM +\texternalCallback := c.onPCM +\tc.mu.RUnlock() +\tif browserCallback != nil { +\t\tbrowserCallback(instanceID, callID, append([]float32(nil), pcm...)) +\t} +\tif externalCallback != nil { +\t\texternalCallback(instanceID, callID, append([]float32(nil), pcm...)) +\t} +} + +func (c *Coordinator) notifyCallMediaCleanup(instanceID, callID string) { +\tc.mu.RLock() +\tcallback := c.onCallMediaCleanup +\tc.mu.RUnlock() +\tif callback != nil { +\t\tcallback(instanceID, callID) +\t} +} + +func (c *Coordinator) notifyInstanceMediaCleanup(instanceID string) { +\tc.mu.RLock() +\tcallback := c.onInstanceMediaCleanup +\tc.mu.RUnlock() +\tif callback != nil { +\t\tcallback(instanceID) +\t} +} +''', + "coordinator PCM callbacks", +) +replace_once( + "pkg/call/lifecycle/coordinator.go", + '''\tc.relays.Remove(instanceID, callID) +\tc.audio.Remove(instanceID, callID) +\tc.packets.Remove(instanceID, callID) +\tc.incoming.Remove(instanceID, callID) +''', + '''\tc.relays.Remove(instanceID, callID) +\tc.audio.Remove(instanceID, callID) +\tc.packets.Remove(instanceID, callID) +\tc.incoming.Remove(instanceID, callID) +\tc.notifyCallMediaCleanup(instanceID, callID) +''', + "coordinator private cleanup", +) + +# Relay lifecycle cleanup notifications. +replace_once( + "pkg/call/voip/media/relay_registry.go", + '''\tonConnected func(instanceID, callID string) +\tonPacket func(instanceID, callID string, packet []byte) +}''', + '''\tonConnected func(instanceID, callID string) +\tonPacket func(instanceID, callID string, packet []byte) +\tonRemoved func(instanceID, callID string) +\tonCleanup func(instanceID string) +}''', + "relay session callbacks", +) +replace_once( + "pkg/call/voip/media/relay_registry.go", + '''func (s *relaySession) remove(callID string) { +\ts.mu.Lock() +\trelay := s.transports[callID] +\tdelete(s.transports, callID) +\tdelete(s.configuring, callID) +\ts.mu.Unlock() +\tif relay != nil { +\t\trelay.Cleanup() +\t} +}''', + '''func (s *relaySession) remove(callID string) { +\ts.mu.Lock() +\trelay := s.transports[callID] +\tdelete(s.transports, callID) +\tdelete(s.configuring, callID) +\tcallback := s.onRemoved +\ts.mu.Unlock() +\tif relay != nil { +\t\trelay.Cleanup() +\t} +\tif callback != nil && callID != "" { +\t\tcallback(s.instanceID, callID) +\t} +}''', + "relay remove callback", +) +replace_once( + "pkg/call/voip/media/relay_registry.go", + '''\ts.configuring = make(map[string]bool) +\ts.mu.Unlock() +\tfor _, relay := range transports { +\t\trelay.Cleanup() +\t} +}''', + '''\ts.configuring = make(map[string]bool) +\tcallback := s.onCleanup +\ts.mu.Unlock() +\tfor _, relay := range transports { +\t\trelay.Cleanup() +\t} +\tif callback != nil { +\t\tcallback(s.instanceID) +\t} +}''', + "relay cleanup callback", +) +replace_once( + "pkg/call/voip/media/relay_registry.go", + '''\tonConnected func(instanceID, callID string) +\tonPacket func(instanceID, callID string, packet []byte) +}''', + '''\tonConnected func(instanceID, callID string) +\tonPacket func(instanceID, callID string, packet []byte) +\tonRemoved func(instanceID, callID string) +\tonCleanup func(instanceID string) +}''', + "relay registry callbacks", +) +replace_once( + "pkg/call/voip/media/relay_registry.go", + '''func (r *RelayRegistry) SetOnPacket(callback func(instanceID, callID string, packet []byte)) { +\tr.mu.Lock() +\tr.onPacket = callback +\tfor _, session := range r.sessions { +\t\tsession.onPacket = callback +\t} +\tr.mu.Unlock() +} +''', + '''func (r *RelayRegistry) SetOnPacket(callback func(instanceID, callID string, packet []byte)) { +\tr.mu.Lock() +\tr.onPacket = callback +\tfor _, session := range r.sessions { +\t\tsession.onPacket = callback +\t} +\tr.mu.Unlock() +} + +func (r *RelayRegistry) SetOnRemoved(callback func(instanceID, callID string)) { +\tr.mu.Lock() +\tr.onRemoved = callback +\tfor _, session := range r.sessions { +\t\tsession.onRemoved = callback +\t} +\tr.mu.Unlock() +} + +func (r *RelayRegistry) SetOnCleanup(callback func(instanceID string)) { +\tr.mu.Lock() +\tr.onCleanup = callback +\tfor _, session := range r.sessions { +\t\tsession.onCleanup = callback +\t} +\tr.mu.Unlock() +} +''', + "relay setters", +) +replace_once( + "pkg/call/voip/media/relay_registry.go", + '''\tcandidate.onConnected = r.onConnected +\tcandidate.onPacket = r.onPacket +''', + '''\tcandidate.onConnected = r.onConnected +\tcandidate.onPacket = r.onPacket +\tcandidate.onRemoved = r.onRemoved +\tcandidate.onCleanup = r.onCleanup +''', + "relay attach callbacks", +) + +# Call service browser API. +replace_once( + "pkg/call/service/call_service.go", + '''\tcall_driver "github.com/evolution-foundation/evolution-go/pkg/call/voip/driver" +''', + '''\tcall_browser "github.com/evolution-foundation/evolution-go/pkg/call/voip/browser" +\tcall_driver "github.com/evolution-foundation/evolution-go/pkg/call/voip/driver" +''', + "service browser import", +) +replace_once( + "pkg/call/service/call_service.go", + '''const signalingTimeout = 30 * time.Second +''', + '''const signalingTimeout = 30 * time.Second + +var ErrCallNotActive = errors.New("call media is not active") +''', + "service active error", +) +replace_once( + "pkg/call/service/call_service.go", + '''\tRejectCall(data *RejectCallStruct, instance *instance_model.Instance) error +\tRuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) +}''', + '''\tRejectCall(data *RejectCallStruct, instance *instance_model.Instance) error +\tRuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) +\tCreateWebRTC(ctx context.Context, callID string, request call_browser.CreateRequest, instance *instance_model.Instance) (call_browser.CreateResponse, error) +\tWebRTCSessions(callID string, instance *instance_model.Instance) ([]call_browser.SessionInfo, error) +\tCloseWebRTC(callID, sessionID string, instance *instance_model.Instance) error +}''', + "service interface", +) +replace_once( + "pkg/call/service/call_service.go", + '''\tloggerWrapper *logger_wrapper.LoggerManager +\tcoordinator *call_lifecycle.Coordinator +}''', + '''\tloggerWrapper *logger_wrapper.LoggerManager +\tcoordinator *call_lifecycle.Coordinator +\tbrowser call_browser.Manager +}''', + "service browser field", +) +replace_once( + "pkg/call/service/call_service.go", + '''func (c *callService) RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) { +\tclient, err := c.ensureClientConnected(instance.Id) +\tif err != nil { +\t\treturn call_runtime.Snapshot{InstanceID: instance.Id}, err +\t} + +\truntime := c.coordinator.RuntimeFor(instance.Id, client) +\treturn runtime.Snapshot(), nil +} +''', + '''func (c *callService) RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) { +\tclient, err := c.ensureClientConnected(instance.Id) +\tif err != nil { +\t\treturn call_runtime.Snapshot{InstanceID: instance.Id}, err +\t} + +\truntime := c.coordinator.RuntimeFor(instance.Id, client) +\treturn runtime.Snapshot(), nil +} + +func (c *callService) CreateWebRTC(ctx context.Context, callID string, request call_browser.CreateRequest, instance *instance_model.Instance) (call_browser.CreateResponse, error) { +\tclient, err := c.ensureClientConnected(instance.Id) +\tif err != nil { +\t\treturn call_browser.CreateResponse{}, err +\t} +\truntime := c.coordinator.RuntimeFor(instance.Id, client) +\tcall, ok := runtime.Call(callID) +\tif !ok { +\t\treturn call_browser.CreateResponse{}, fmt.Errorf("call %s not found", callID) +\t} +\tif call.State != call_runtime.StateActive { +\t\treturn call_browser.CreateResponse{}, fmt.Errorf("%w: call %s is %s", ErrCallNotActive, callID, call.State) +\t} +\treturn c.browser.Create(ctx, instance.Id, callID, request) +} + +func (c *callService) WebRTCSessions(callID string, instance *instance_model.Instance) ([]call_browser.SessionInfo, error) { +\tif callID == "" { +\t\treturn nil, fmt.Errorf("callId is required") +\t} +\treturn c.browser.Sessions(instance.Id, callID) +} + +func (c *callService) CloseWebRTC(callID, sessionID string, instance *instance_model.Instance) error { +\tif callID == "" || sessionID == "" { +\t\treturn call_browser.ErrSessionNotFound +\t} +\treturn c.browser.CloseSession(instance.Id, callID, sessionID) +} +''', + "service browser methods", +) +replace_once( + "pkg/call/service/call_service.go", + '''\treturn &callService{ +\t\tclientPointer: clientPointer, +\t\twhatsmeowService: whatsmeowService, +\t\tloggerWrapper: loggerWrapper, +\t\tcoordinator: coordinator, +\t} +}''', + '''\tservice := &callService{ +\t\tclientPointer: clientPointer, +\t\twhatsmeowService: whatsmeowService, +\t\tloggerWrapper: loggerWrapper, +\t\tcoordinator: coordinator, +\t} +\tservice.browser = call_browser.NewManager(coordinator.FeedPCM) +\tcoordinator.SetBrowserPCM(service.browser.HandlePCM) +\tcoordinator.SetMediaCleanupHooks(service.browser.CloseCall, service.browser.CloseInstance) +\treturn service +}''', + "service constructor", +) + +# Handler endpoints and status mapping. +replace_once( + "pkg/call/handler/call_handler.go", + '''import ( +\t"net/http" + +\tcall_service "github.com/evolution-foundation/evolution-go/pkg/call/service" +''', + '''import ( +\t"context" +\t"errors" +\t"net/http" +\t"time" + +\tcall_service "github.com/evolution-foundation/evolution-go/pkg/call/service" +\tcall_browser "github.com/evolution-foundation/evolution-go/pkg/call/voip/browser" +''', + "handler imports", +) +replace_once( + "pkg/call/handler/call_handler.go", + '''\tRejectCall(ctx *gin.Context) +\tStatus(ctx *gin.Context) +}''', + '''\tRejectCall(ctx *gin.Context) +\tStatus(ctx *gin.Context) +\tCreateWebRTC(ctx *gin.Context) +\tListWebRTC(ctx *gin.Context) +\tCloseWebRTC(ctx *gin.Context) +}''', + "handler interface", +) +replace_once( + "pkg/call/handler/call_handler.go", + '''func NewCallHandler(callService call_service.CallService) CallHandler { +\treturn &callHandler{callService: callService} +} +''', + '''func browserHTTPStatus(err error) int { +\tswitch { +\tcase errors.Is(err, call_browser.ErrWebRTCDisabled): +\t\treturn http.StatusNotImplemented +\tcase errors.Is(err, call_browser.ErrInvalidOffer), errors.Is(err, call_browser.ErrInvalidPCMMessage): +\t\treturn http.StatusBadRequest +\tcase errors.Is(err, call_browser.ErrSessionNotFound): +\t\treturn http.StatusNotFound +\tcase errors.Is(err, call_browser.ErrSessionLimit), errors.Is(err, call_service.ErrCallNotActive): +\t\treturn http.StatusConflict +\tcase errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): +\t\treturn http.StatusGatewayTimeout +\tdefault: +\t\treturn http.StatusInternalServerError +\t} +} + +// Create browser WebRTC PCM session +// @Summary Create an experimental browser PCM bridge +// @Description Exchanges a complete SDP offer/answer. Requires the voip_pion build and an active WhatsApp call. +// @Tags Call +// @Accept json +// @Produce json +// @Param callId path string true "Call ID" +// @Param offer body call_browser.CreateRequest true "Browser SDP offer" +// @Success 201 {object} call_browser.CreateResponse +// @Router /call/{callId}/webrtc [post] +func (g *callHandler) CreateWebRTC(ctx *gin.Context) { +\tinstance, ok := instanceFromContext(ctx) +\tif !ok { +\t\treturn +\t} +\tcallID := ctx.Param("callId") +\tif callID == "" { +\t\tctx.JSON(http.StatusBadRequest, gin.H{"error": "callId is required"}) +\t\treturn +\t} +\tvar request call_browser.CreateRequest +\tif err := ctx.ShouldBindJSON(&request); err != nil { +\t\tctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +\t\treturn +\t} +\trequestContext, cancel := context.WithTimeout(ctx.Request.Context(), 30*time.Second) +\tdefer cancel() +\tresponse, err := g.callService.CreateWebRTC(requestContext, callID, request, instance) +\tif err != nil { +\t\tctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()}) +\t\treturn +\t} +\tctx.JSON(http.StatusCreated, response) +} + +// List browser WebRTC PCM sessions +// @Summary List browser PCM bridge sessions +// @Tags Call +// @Produce json +// @Param callId path string true "Call ID" +// @Success 200 {object} gin.H +// @Router /call/{callId}/webrtc [get] +func (g *callHandler) ListWebRTC(ctx *gin.Context) { +\tinstance, ok := instanceFromContext(ctx) +\tif !ok { +\t\treturn +\t} +\tsessions, err := g.callService.WebRTCSessions(ctx.Param("callId"), instance) +\tif err != nil { +\t\tctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()}) +\t\treturn +\t} +\tctx.JSON(http.StatusOK, gin.H{"sessions": sessions}) +} + +// Close browser WebRTC PCM session +// @Summary Close a browser PCM bridge session +// @Tags Call +// @Produce json +// @Param callId path string true "Call ID" +// @Param sessionId path string true "WebRTC session ID" +// @Success 200 {object} gin.H +// @Router /call/{callId}/webrtc/{sessionId} [delete] +func (g *callHandler) CloseWebRTC(ctx *gin.Context) { +\tinstance, ok := instanceFromContext(ctx) +\tif !ok { +\t\treturn +\t} +\tif err := g.callService.CloseWebRTC(ctx.Param("callId"), ctx.Param("sessionId"), instance); err != nil { +\t\tctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()}) +\t\treturn +\t} +\tctx.JSON(http.StatusOK, gin.H{"message": "browser media session closed"}) +} + +func NewCallHandler(callService call_service.CallService) CallHandler { +\treturn &callHandler{callService: callService} +} +''', + "handler browser methods", +) + +# Routes. +replace_once( + "pkg/routes/routes.go", + '''\t\troutes.POST("/:callId/accept", r.callHandler.AcceptCall) +\t\troutes.DELETE("/:callId", r.callHandler.TerminateCall) +\t\troutes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall) +''', + '''\t\troutes.POST("/:callId/accept", r.callHandler.AcceptCall) +\t\troutes.POST("/:callId/webrtc", r.callHandler.CreateWebRTC) +\t\troutes.GET("/:callId/webrtc", r.callHandler.ListWebRTC) +\t\troutes.DELETE("/:callId/webrtc/:sessionId", r.callHandler.CloseWebRTC) +\t\troutes.DELETE("/:callId", r.callHandler.TerminateCall) +\t\troutes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall) +''', + "browser routes", +) + +# Remove migration artifacts from the commit produced by the workflow. +for relative in ["tools/integrate_browser_webrtc.py", ".github/workflows/integrate-browser-webrtc.yml"]: + target = ROOT / relative + if target.exists(): + target.unlink() From e49ed69a63f9d8113211c159f055829f5db06b76 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:13:47 -0300 Subject: [PATCH 128/266] chore(call): run browser WebRTC PCM bridge migration --- .../workflows/integrate-browser-webrtc.yml | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/integrate-browser-webrtc.yml diff --git a/.github/workflows/integrate-browser-webrtc.yml b/.github/workflows/integrate-browser-webrtc.yml new file mode 100644 index 00000000..01b2af58 --- /dev/null +++ b/.github/workflows/integrate-browser-webrtc.yml @@ -0,0 +1,44 @@ +name: Integrate browser WebRTC PCM + +on: + push: + branches: + - dev/astracalls-integration + paths: + - tools/integrate_browser_webrtc.py + - .github/workflows/integrate-browser-webrtc.yml + +permissions: + contents: write + +jobs: + integrate: + runs-on: ubuntu-latest + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: dev/astracalls-integration + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Apply browser bridge migration + run: python3 tools/integrate_browser_webrtc.py + - name: Format implementation + run: gofmt -w pkg/call/voip/browser pkg/call/lifecycle/coordinator.go pkg/call/voip/media/relay_registry.go pkg/call/service/call_service.go pkg/call/handler/call_handler.go pkg/routes/routes.go + - name: Test default call build + run: go test -race ./pkg/call/... + - name: Test experimental Pion build + run: go test -race -tags=voip_pion ./pkg/call/... + - name: Commit browser bridge + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "feat(call): add authenticated browser WebRTC PCM bridge" + git push origin HEAD:dev/astracalls-integration From 85fd460312dbf87debd6f06b2a509d6e95a3ce5b Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:15:25 -0300 Subject: [PATCH 129/266] feat(call): add browser WebRTC PCM contract --- pkg/call/voip/browser/types.go | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 pkg/call/voip/browser/types.go diff --git a/pkg/call/voip/browser/types.go b/pkg/call/voip/browser/types.go new file mode 100644 index 00000000..2858e3aa --- /dev/null +++ b/pkg/call/voip/browser/types.go @@ -0,0 +1,89 @@ +package browser + +import ( + "context" + "errors" + "time" +) + +const ( + DataChannelLabel = "evolution-call-pcm" + DataChannelProtocol = "evcall.pcm.v1" + PCMSampleRate = 16000 + PCMChannels = 1 + PCMFrameSamples = 960 +) + +var ( + ErrWebRTCDisabled = errors.New("browser WebRTC bridge requires the voip_pion build tag") + ErrInvalidOffer = errors.New("invalid WebRTC SDP offer") + ErrSessionNotFound = errors.New("browser WebRTC session not found") + ErrSessionLimit = errors.New("browser WebRTC session limit reached") + ErrInvalidPCMMessage = errors.New("invalid browser PCM message") +) + +type SDPDescription struct { + Type string `json:"type" binding:"required"` + SDP string `json:"sdp" binding:"required"` +} + +type CreateRequest struct { + Offer SDPDescription `json:"offer" binding:"required"` +} + +type ProtocolInfo struct { + DataChannel string `json:"dataChannel"` + Protocol string `json:"protocol"` + Format string `json:"format"` + SampleRate int `json:"sampleRate"` + Channels int `json:"channels"` + FrameSamples int `json:"frameSamples"` +} + +type CreateResponse struct { + SessionID string `json:"sessionId"` + Answer SDPDescription `json:"answer"` + Audio ProtocolInfo `json:"audio"` +} + +type SessionState string + +const ( + SessionStateConnecting SessionState = "connecting" + SessionStateOpen SessionState = "open" + SessionStateClosed SessionState = "closed" + SessionStateFailed SessionState = "failed" +) + +type SessionInfo struct { + SessionID string `json:"sessionId"` + CallID string `json:"callId"` + State SessionState `json:"state"` + ChannelOpen bool `json:"channelOpen"` + CreatedAt time.Time `json:"createdAt"` + InputFrames uint64 `json:"inputFrames"` + OutputFrames uint64 `json:"outputFrames"` + DroppedFrames uint64 `json:"droppedFrames"` +} + +type PCMFeeder func(instanceID, callID string, pcm []float32) error + +type Manager interface { + Create(ctx context.Context, instanceID, callID string, request CreateRequest) (CreateResponse, error) + Sessions(instanceID, callID string) ([]SessionInfo, error) + CloseSession(instanceID, callID, sessionID string) error + CloseCall(instanceID, callID string) + CloseInstance(instanceID string) + HandlePCM(instanceID, callID string, pcm []float32) +} + +func DefaultProtocolInfo() ProtocolInfo { + return ProtocolInfo{ + DataChannel: DataChannelLabel, + Protocol: DataChannelProtocol, + Format: "f32le", + SampleRate: PCMSampleRate, + Channels: PCMChannels, + FrameSamples: PCMFrameSamples, + } +} From 0d06ea72070416612d4c4195079aaf75d6f73c9d Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:15:41 -0300 Subject: [PATCH 130/266] feat(call): add versioned browser PCM framing --- pkg/call/voip/browser/frame.go | 77 ++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 pkg/call/voip/browser/frame.go diff --git a/pkg/call/voip/browser/frame.go b/pkg/call/voip/browser/frame.go new file mode 100644 index 00000000..3b041fdf --- /dev/null +++ b/pkg/call/voip/browser/frame.go @@ -0,0 +1,77 @@ +package browser + +import ( + "encoding/binary" + "fmt" + "math" +) + +const ( + pcmHeaderSize = 16 + pcmVersion = 1 + pcmKind = 1 + maxPCMSamples = PCMFrameSamples * 4 +) + +var pcmMagic = [4]byte{'E', 'V', 'P', 'C'} + +func EncodePCMFrame(pcm []float32) ([]byte, error) { + if len(pcm) == 0 || len(pcm) > maxPCMSamples { + return nil, fmt.Errorf("%w: sample count %d", ErrInvalidPCMMessage, len(pcm)) + } + output := make([]byte, pcmHeaderSize+len(pcm)*4) + copy(output[:4], pcmMagic[:]) + output[4] = pcmVersion + output[5] = pcmKind + binary.LittleEndian.PutUint16(output[6:8], 0) + binary.LittleEndian.PutUint32(output[8:12], PCMSampleRate) + binary.LittleEndian.PutUint32(output[12:16], uint32(len(pcm))) + offset := pcmHeaderSize + for _, sample := range pcm { + binary.LittleEndian.PutUint32(output[offset:offset+4], math.Float32bits(sample)) + offset += 4 + } + return output, nil +} + +func DecodePCMFrame(frame []byte) ([]float32, error) { + if len(frame) < pcmHeaderSize { + return nil, fmt.Errorf("%w: frame has %d bytes", ErrInvalidPCMMessage, len(frame)) + } + if string(frame[:4]) != string(pcmMagic[:]) || frame[4] != pcmVersion || frame[5] != pcmKind { + return nil, fmt.Errorf("%w: unsupported framing", ErrInvalidPCMMessage) + } + if binary.LittleEndian.Uint16(frame[6:8]) != 0 { + return nil, fmt.Errorf("%w: unsupported flags", ErrInvalidPCMMessage) + } + if binary.LittleEndian.Uint32(frame[8:12]) != PCMSampleRate { + return nil, fmt.Errorf("%w: sample rate must be %d", ErrInvalidPCMMessage, PCMSampleRate) + } + sampleCount := int(binary.LittleEndian.Uint32(frame[12:16])) + if sampleCount <= 0 || sampleCount > maxPCMSamples { + return nil, fmt.Errorf("%w: sample count %d", ErrInvalidPCMMessage, sampleCount) + } + expected := pcmHeaderSize + sampleCount*4 + if len(frame) != expected { + return nil, fmt.Errorf("%w: frame has %d bytes, want %d", ErrInvalidPCMMessage, len(frame), expected) + } + pcm := make([]float32, sampleCount) + offset := pcmHeaderSize + for index := range pcm { + pcm[index] = math.Float32frombits(binary.LittleEndian.Uint32(frame[offset : offset+4])) + offset += 4 + } + return pcm, nil +} + +func zeroPCM(values []float32) { + for index := range values { + values[index] = 0 + } +} + +func zeroFrame(value []byte) { + for index := range value { + value[index] = 0 + } +} From 58ccf22c6ea5e702bc8e8443adbc9a67a86bbff4 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:15:50 -0300 Subject: [PATCH 131/266] feat(call): keep browser bridge disabled by default --- pkg/call/voip/browser/manager_default.go | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 pkg/call/voip/browser/manager_default.go diff --git a/pkg/call/voip/browser/manager_default.go b/pkg/call/voip/browser/manager_default.go new file mode 100644 index 00000000..e948e7ba --- /dev/null +++ b/pkg/call/voip/browser/manager_default.go @@ -0,0 +1,27 @@ +//go:build !voip_pion + +package browser + +import "context" + +type disabledManager struct{} + +func NewManager(PCMFeeder) Manager { + return &disabledManager{} +} + +func (*disabledManager) Create(context.Context, string, string, CreateRequest) (CreateResponse, error) { + return CreateResponse{}, ErrWebRTCDisabled +} + +func (*disabledManager) Sessions(string, string) ([]SessionInfo, error) { + return nil, ErrWebRTCDisabled +} + +func (*disabledManager) CloseSession(string, string, string) error { + return ErrWebRTCDisabled +} + +func (*disabledManager) CloseCall(string, string) {} +func (*disabledManager) CloseInstance(string) {} +func (*disabledManager) HandlePCM(string, string, []float32) {} From e78ae48c2e26abbab8857294dae076b2a2032616 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:16:31 -0300 Subject: [PATCH 132/266] feat(call): add Pion browser PCM bridge manager --- pkg/call/voip/browser/manager_pion.go | 483 ++++++++++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 pkg/call/voip/browser/manager_pion.go diff --git a/pkg/call/voip/browser/manager_pion.go b/pkg/call/voip/browser/manager_pion.go new file mode 100644 index 00000000..30d90d0a --- /dev/null +++ b/pkg/call/voip/browser/manager_pion.go @@ -0,0 +1,483 @@ +//go:build voip_pion + +package browser + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/pion/webrtc/v4" +) + +const ( + maxSessionsPerCall = 4 + maxOfferBytes = 256 * 1024 + maxBufferedAmount = 512 * 1024 + mediaQueueDepth = 8 +) + +type pionManager struct { + mu sync.RWMutex + feeder PCMFeeder + sessions map[string]map[string]map[string]*pionSession +} + +type pionSession struct { + manager *pionManager + instanceID string + callID string + id string + createdAt time.Time + pc *webrtc.PeerConnection + + mu sync.RWMutex + channel *webrtc.DataChannel + state SessionState + inputFrames uint64 + outputFrames uint64 + droppedFrames uint64 + + incoming chan []float32 + outgoing chan []byte + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewManager(feeder PCMFeeder) Manager { + return &pionManager{ + feeder: feeder, + sessions: make(map[string]map[string]map[string]*pionSession), + } +} + +func (m *pionManager) Create(ctx context.Context, instanceID, callID string, request CreateRequest) (CreateResponse, error) { + if m == nil || instanceID == "" || callID == "" { + return CreateResponse{}, ErrInvalidOffer + } + offerType := strings.ToLower(strings.TrimSpace(request.Offer.Type)) + if offerType != "offer" || request.Offer.SDP == "" || len(request.Offer.SDP) > maxOfferBytes { + return CreateResponse{}, ErrInvalidOffer + } + if ctx == nil { + ctx = context.Background() + } + + m.mu.Lock() + calls := m.sessions[instanceID] + if calls == nil { + calls = make(map[string]map[string]*pionSession) + m.sessions[instanceID] = calls + } + callSessions := calls[callID] + if callSessions == nil { + callSessions = make(map[string]*pionSession) + calls[callID] = callSessions + } + if len(callSessions) >= maxSessionsPerCall { + m.mu.Unlock() + return CreateResponse{}, ErrSessionLimit + } + sessionID := uuid.NewString() + m.mu.Unlock() + + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + return CreateResponse{}, fmt.Errorf("create browser peer connection: %w", err) + } + session := &pionSession{ + manager: m, + instanceID: instanceID, + callID: callID, + id: sessionID, + createdAt: time.Now().UTC(), + pc: pc, + state: SessionStateConnecting, + incoming: make(chan []float32, mediaQueueDepth), + outgoing: make(chan []byte, mediaQueueDepth), + stopCh: make(chan struct{}), + } + session.wg.Add(2) + go session.inputLoop() + go session.outputLoop() + + m.mu.Lock() + calls = m.sessions[instanceID] + if calls == nil { + calls = make(map[string]map[string]*pionSession) + m.sessions[instanceID] = calls + } + callSessions = calls[callID] + if callSessions == nil { + callSessions = make(map[string]*pionSession) + calls[callID] = callSessions + } + if len(callSessions) >= maxSessionsPerCall { + m.mu.Unlock() + session.close() + return CreateResponse{}, ErrSessionLimit + } + callSessions[sessionID] = session + m.mu.Unlock() + + fail := func(cause error) (CreateResponse, error) { + _ = m.CloseSession(instanceID, callID, sessionID) + return CreateResponse{}, cause + } + + pc.OnDataChannel(func(channel *webrtc.DataChannel) { + session.attachDataChannel(channel) + }) + pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + switch state { + case webrtc.PeerConnectionStateFailed: + session.setState(SessionStateFailed) + go func() { _ = m.CloseSession(instanceID, callID, sessionID) }() + case webrtc.PeerConnectionStateClosed: + go func() { _ = m.CloseSession(instanceID, callID, sessionID) }() + } + }) + + remote := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: request.Offer.SDP} + if err = pc.SetRemoteDescription(remote); err != nil { + return fail(fmt.Errorf("set browser remote description: %w", err)) + } + answer, err := pc.CreateAnswer(nil) + if err != nil { + return fail(fmt.Errorf("create browser SDP answer: %w", err)) + } + gatheringComplete := webrtc.GatheringCompletePromise(pc) + if err = pc.SetLocalDescription(answer); err != nil { + return fail(fmt.Errorf("set browser local description: %w", err)) + } + select { + case <-gatheringComplete: + case <-ctx.Done(): + return fail(fmt.Errorf("gather browser ICE candidates: %w", ctx.Err())) + } + local := pc.LocalDescription() + if local == nil || local.SDP == "" { + return fail(fmt.Errorf("create browser SDP answer: empty local description")) + } + + return CreateResponse{ + SessionID: sessionID, + Answer: SDPDescription{Type: "answer", SDP: local.SDP}, + Audio: DefaultProtocolInfo(), + }, nil +} + +func (m *pionManager) Sessions(instanceID, callID string) ([]SessionInfo, error) { + if m == nil { + return nil, ErrSessionNotFound + } + sessions := m.snapshot(instanceID, callID) + result := make([]SessionInfo, 0, len(sessions)) + for _, session := range sessions { + result = append(result, session.info()) + } + sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt.Before(result[j].CreatedAt) }) + return result, nil +} + +func (m *pionManager) CloseSession(instanceID, callID, sessionID string) error { + if m == nil || sessionID == "" { + return ErrSessionNotFound + } + m.mu.Lock() + calls := m.sessions[instanceID] + callSessions := calls[callID] + session := callSessions[sessionID] + if session != nil { + delete(callSessions, sessionID) + if len(callSessions) == 0 { + delete(calls, callID) + } + if len(calls) == 0 { + delete(m.sessions, instanceID) + } + } + m.mu.Unlock() + if session == nil { + return ErrSessionNotFound + } + session.close() + return nil +} + +func (m *pionManager) CloseCall(instanceID, callID string) { + for _, session := range m.takeCall(instanceID, callID) { + session.close() + } +} + +func (m *pionManager) CloseInstance(instanceID string) { + if m == nil { + return + } + m.mu.Lock() + calls := m.sessions[instanceID] + delete(m.sessions, instanceID) + m.mu.Unlock() + for _, callSessions := range calls { + for _, session := range callSessions { + session.close() + } + } +} + +func (m *pionManager) HandlePCM(instanceID, callID string, pcm []float32) { + if len(pcm) == 0 { + return + } + frame, err := EncodePCMFrame(pcm) + if err != nil { + return + } + defer zeroFrame(frame) + for _, session := range m.snapshot(instanceID, callID) { + session.enqueueOutgoing(append([]byte(nil), frame...)) + } +} + +func (m *pionManager) snapshot(instanceID, callID string) []*pionSession { + if m == nil { + return nil + } + m.mu.RLock() + callSessions := m.sessions[instanceID][callID] + result := make([]*pionSession, 0, len(callSessions)) + for _, session := range callSessions { + result = append(result, session) + } + m.mu.RUnlock() + return result +} + +func (m *pionManager) takeCall(instanceID, callID string) []*pionSession { + if m == nil { + return nil + } + m.mu.Lock() + calls := m.sessions[instanceID] + callSessions := calls[callID] + delete(calls, callID) + if len(calls) == 0 { + delete(m.sessions, instanceID) + } + result := make([]*pionSession, 0, len(callSessions)) + for _, session := range callSessions { + result = append(result, session) + } + m.mu.Unlock() + return result +} + +func (s *pionSession) attachDataChannel(channel *webrtc.DataChannel) { + if channel == nil || channel.Label() != DataChannelLabel { + if channel != nil { + _ = channel.Close() + } + s.incrementDropped() + go func() { _ = s.manager.CloseSession(s.instanceID, s.callID, s.id) }() + return + } + if protocol := channel.Protocol(); protocol != "" && protocol != DataChannelProtocol { + _ = channel.Close() + s.incrementDropped() + go func() { _ = s.manager.CloseSession(s.instanceID, s.callID, s.id) }() + return + } + + s.mu.Lock() + if s.channel != nil || s.state == SessionStateClosed || s.state == SessionStateFailed { + s.mu.Unlock() + _ = channel.Close() + return + } + s.channel = channel + s.mu.Unlock() + + channel.SetBufferedAmountLowThreshold(maxBufferedAmount / 2) + channel.OnOpen(func() { s.setState(SessionStateOpen) }) + channel.OnClose(func() { + go func() { _ = s.manager.CloseSession(s.instanceID, s.callID, s.id) }() + }) + channel.OnMessage(func(message webrtc.DataChannelMessage) { + if message.IsString { + s.incrementDropped() + return + } + pcm, err := DecodePCMFrame(message.Data) + if err != nil { + s.incrementDropped() + return + } + s.enqueueIncoming(pcm) + }) +} + +func (s *pionSession) enqueueIncoming(pcm []float32) { + select { + case <-s.stopCh: + zeroPCM(pcm) + case s.incoming <- pcm: + default: + zeroPCM(pcm) + s.incrementDropped() + } +} + +func (s *pionSession) enqueueOutgoing(frame []byte) { + if !s.isOpen() { + zeroFrame(frame) + s.incrementDropped() + return + } + select { + case <-s.stopCh: + zeroFrame(frame) + case s.outgoing <- frame: + default: + zeroFrame(frame) + s.incrementDropped() + } +} + +func (s *pionSession) inputLoop() { + defer s.wg.Done() + for { + select { + case <-s.stopCh: + return + case pcm := <-s.incoming: + if s.manager.feeder != nil { + if err := s.manager.feeder(s.instanceID, s.callID, pcm); err != nil { + s.incrementDropped() + } else { + s.mu.Lock() + s.inputFrames++ + s.mu.Unlock() + } + } else { + s.incrementDropped() + } + zeroPCM(pcm) + } + } +} + +func (s *pionSession) outputLoop() { + defer s.wg.Done() + for { + select { + case <-s.stopCh: + return + case frame := <-s.outgoing: + s.sendFrame(frame) + zeroFrame(frame) + } + } +} + +func (s *pionSession) sendFrame(frame []byte) { + s.mu.RLock() + channel := s.channel + open := s.state == SessionStateOpen && channel != nil + s.mu.RUnlock() + if !open || channel.BufferedAmount() > maxBufferedAmount { + s.incrementDropped() + return + } + if err := channel.Send(frame); err != nil { + s.incrementDropped() + return + } + s.mu.Lock() + s.outputFrames++ + s.mu.Unlock() +} + +func (s *pionSession) setState(state SessionState) { + s.mu.Lock() + if s.state != SessionStateClosed { + s.state = state + } + s.mu.Unlock() +} + +func (s *pionSession) isOpen() bool { + s.mu.RLock() + open := s.state == SessionStateOpen && s.channel != nil + s.mu.RUnlock() + return open +} + +func (s *pionSession) incrementDropped() { + s.mu.Lock() + s.droppedFrames++ + s.mu.Unlock() +} + +func (s *pionSession) info() SessionInfo { + s.mu.RLock() + info := SessionInfo{ + SessionID: s.id, + CallID: s.callID, + State: s.state, + ChannelOpen: s.state == SessionStateOpen && s.channel != nil, + CreatedAt: s.createdAt, + InputFrames: s.inputFrames, + OutputFrames: s.outputFrames, + DroppedFrames: s.droppedFrames, + } + s.mu.RUnlock() + return info +} + +func (s *pionSession) close() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + s.mu.Lock() + s.state = SessionStateClosed + channel := s.channel + s.channel = nil + pc := s.pc + s.pc = nil + s.mu.Unlock() + if channel != nil { + _ = channel.Close() + } + if pc != nil { + _ = pc.Close() + } + s.wg.Wait() + for { + select { + case pcm := <-s.incoming: + zeroPCM(pcm) + default: + goto outgoing + } + } + outgoing: + for { + select { + case frame := <-s.outgoing: + zeroFrame(frame) + default: + return + } + } + }) +} + +var _ Manager = (*pionManager)(nil) From ceaaf1fffb48e640fa60c6325d8aa54ad2c4e8f4 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:16:42 -0300 Subject: [PATCH 133/266] test(call): cover browser PCM framing --- pkg/call/voip/browser/frame_test.go | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 pkg/call/voip/browser/frame_test.go diff --git a/pkg/call/voip/browser/frame_test.go b/pkg/call/voip/browser/frame_test.go new file mode 100644 index 00000000..03109d12 --- /dev/null +++ b/pkg/call/voip/browser/frame_test.go @@ -0,0 +1,54 @@ +package browser + +import ( + "errors" + "math" + "testing" +) + +func TestPCMFrameRoundTrip(t *testing.T) { + input := []float32{-1, -0.25, 0, 0.5, 1} + frame, err := EncodePCMFrame(input) + if err != nil { + t.Fatal(err) + } + output, err := DecodePCMFrame(frame) + if err != nil { + t.Fatal(err) + } + if len(output) != len(input) { + t.Fatalf("decoded %d samples, want %d", len(output), len(input)) + } + for index := range input { + if math.Float32bits(output[index]) != math.Float32bits(input[index]) { + t.Fatalf("sample %d=%v, want %v", index, output[index], input[index]) + } + } +} + +func TestPCMFrameRejectsMalformedInput(t *testing.T) { + frame, err := EncodePCMFrame(make([]float32, PCMFrameSamples)) + if err != nil { + t.Fatal(err) + } + cases := [][]byte{ + nil, + frame[:10], + append([]byte(nil), frame[:len(frame)-1]...), + append([]byte("BAD!"), frame[4:]...), + } + for _, value := range cases { + if _, err = DecodePCMFrame(value); !errors.Is(err, ErrInvalidPCMMessage) { + t.Fatalf("expected invalid PCM error, got %v", err) + } + } +} + +func TestPCMFrameLimitsSamples(t *testing.T) { + if _, err := EncodePCMFrame(nil); !errors.Is(err, ErrInvalidPCMMessage) { + t.Fatalf("expected empty frame error, got %v", err) + } + if _, err := EncodePCMFrame(make([]float32, maxPCMSamples+1)); !errors.Is(err, ErrInvalidPCMMessage) { + t.Fatalf("expected oversized frame error, got %v", err) + } +} From 3810aa21095a14408bd23272f6cd442d93fd7220 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:16:49 -0300 Subject: [PATCH 134/266] test(call): verify default browser bridge remains disabled --- pkg/call/voip/browser/manager_default_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 pkg/call/voip/browser/manager_default_test.go diff --git a/pkg/call/voip/browser/manager_default_test.go b/pkg/call/voip/browser/manager_default_test.go new file mode 100644 index 00000000..cb608757 --- /dev/null +++ b/pkg/call/voip/browser/manager_default_test.go @@ -0,0 +1,17 @@ +//go:build !voip_pion + +package browser + +import ( + "context" + "errors" + "testing" +) + +func TestDefaultManagerIsDisabled(t *testing.T) { + manager := NewManager(nil) + _, err := manager.Create(context.Background(), "instance", "call", CreateRequest{}) + if !errors.Is(err, ErrWebRTCDisabled) { + t.Fatalf("expected disabled error, got %v", err) + } +} From 9873f7b4daa92ed625c7715cb938fbd97d857eb6 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:17:06 -0300 Subject: [PATCH 135/266] test(call): exercise browser WebRTC PCM loopback --- pkg/call/voip/browser/manager_pion_test.go | 116 +++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 pkg/call/voip/browser/manager_pion_test.go diff --git a/pkg/call/voip/browser/manager_pion_test.go b/pkg/call/voip/browser/manager_pion_test.go new file mode 100644 index 00000000..ebfdce3a --- /dev/null +++ b/pkg/call/voip/browser/manager_pion_test.go @@ -0,0 +1,116 @@ +//go:build voip_pion + +package browser + +import ( + "context" + "testing" + "time" + + "github.com/pion/webrtc/v4" +) + +func TestPionManagerBridgesPCMOverDataChannel(t *testing.T) { + fed := make(chan []float32, 2) + manager := NewManager(func(_, _ string, pcm []float32) error { + fed <- append([]float32(nil), pcm...) + return nil + }) + defer manager.CloseInstance("instance") + + client, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + protocol := DataChannelProtocol + channel, err := client.CreateDataChannel(DataChannelLabel, &webrtc.DataChannelInit{Protocol: &protocol}) + if err != nil { + t.Fatal(err) + } + opened := make(chan struct{}) + received := make(chan []float32, 2) + channel.OnOpen(func() { close(opened) }) + channel.OnMessage(func(message webrtc.DataChannelMessage) { + pcm, decodeErr := DecodePCMFrame(message.Data) + if decodeErr != nil { + t.Errorf("decode server PCM: %v", decodeErr) + return + } + received <- pcm + }) + + offer, err := client.CreateOffer(nil) + if err != nil { + t.Fatal(err) + } + gather := webrtc.GatheringCompletePromise(client) + if err = client.SetLocalDescription(offer); err != nil { + t.Fatal(err) + } + select { + case <-gather: + case <-time.After(10 * time.Second): + t.Fatal("client ICE gathering timed out") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + response, err := manager.Create(ctx, "instance", "call", CreateRequest{Offer: SDPDescription{ + Type: "offer", + SDP: client.LocalDescription().SDP, + }}) + if err != nil { + t.Fatal(err) + } + if err = client.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: response.Answer.SDP}); err != nil { + t.Fatal(err) + } + select { + case <-opened: + case <-time.After(10 * time.Second): + t.Fatal("PCM data channel did not open") + } + + browserPCM := make([]float32, PCMFrameSamples) + browserPCM[0] = 0.25 + frame, err := EncodePCMFrame(browserPCM) + if err != nil { + t.Fatal(err) + } + if err = channel.Send(frame); err != nil { + t.Fatal(err) + } + select { + case got := <-fed: + if len(got) != PCMFrameSamples || got[0] != 0.25 { + t.Fatalf("unexpected fed PCM: len=%d first=%v", len(got), got[0]) + } + case <-time.After(10 * time.Second): + t.Fatal("server did not receive browser PCM") + } + + serverPCM := make([]float32, PCMFrameSamples) + serverPCM[0] = -0.5 + manager.HandlePCM("instance", "call", serverPCM) + select { + case got := <-received: + if len(got) != PCMFrameSamples || got[0] != -0.5 { + t.Fatalf("unexpected browser PCM: len=%d first=%v", len(got), got[0]) + } + case <-time.After(10 * time.Second): + t.Fatal("browser did not receive server PCM") + } + + sessions, err := manager.Sessions("instance", "call") + if err != nil || len(sessions) != 1 || !sessions[0].ChannelOpen { + t.Fatalf("unexpected sessions: %+v err=%v", sessions, err) + } + if err = manager.CloseSession("instance", "call", response.SessionID); err != nil { + t.Fatal(err) + } + sessions, err = manager.Sessions("instance", "call") + if err != nil || len(sessions) != 0 { + t.Fatalf("session was not removed: %+v err=%v", sessions, err) + } +} From 7b0d4f6828852bf0adc8a593c11eba4733fe359b Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:17:40 -0300 Subject: [PATCH 136/266] feat(call): connect browser PCM and media cleanup lifecycle --- pkg/call/lifecycle/coordinator.go | 85 ++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go index 1f1e3a9b..34653a30 100644 --- a/pkg/call/lifecycle/coordinator.go +++ b/pkg/call/lifecycle/coordinator.go @@ -25,7 +25,12 @@ type Coordinator struct { relays *call_media.RelayRegistry packets *call_media.PacketRegistry audio *call_media.AudioRegistry - onRTP func(instanceID, callID string, packet *call_media.RTPPacket) + + onRTP func(instanceID, callID string, packet *call_media.RTPPacket) + onPCM func(instanceID, callID string, pcm []float32) + browserPCM func(instanceID, callID string, pcm []float32) + onCallMediaCleanup func(instanceID, callID string) + onInstanceMediaCleanup func(instanceID string) incomingEnabled map[string]bool } @@ -42,7 +47,18 @@ func NewCoordinator() *Coordinator { coordinator.audio = call_media.NewAudioRegistry(func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { return coordinator.SendOpus(instanceID, callID, payload, durationSamples, marker) }, nil) + coordinator.audio.SetOnPCM(coordinator.dispatchPCM) coordinator.relays = call_media.NewRelayRegistry(incoming, nil, nil) + coordinator.relays.SetOnRemoved(func(instanceID, callID string) { + coordinator.audio.Remove(instanceID, callID) + coordinator.packets.Remove(instanceID, callID) + coordinator.notifyCallMediaCleanup(instanceID, callID) + }) + coordinator.relays.SetOnCleanup(func(instanceID string) { + coordinator.audio.Close(instanceID) + coordinator.packets.Close(instanceID) + coordinator.notifyInstanceMediaCleanup(instanceID) + }) coordinator.relays.SetOnConnected(func(instanceID, callID string) { if err := coordinator.packets.Prepare(instanceID, callID); err != nil { return @@ -92,7 +108,8 @@ func (c *Coordinator) AttachClient(instanceID string, client *whatsmeow.Client, } // DetachClient removes handlers, relay connections, codec sessions, packet -// contexts, configuration and private call keys before the client is discarded. +// contexts, browser media, configuration and private call keys before the +// client is discarded. func (c *Coordinator) DetachClient(instanceID string) { if c == nil || instanceID == "" { return @@ -103,6 +120,7 @@ func (c *Coordinator) DetachClient(instanceID string) { c.relays.Close(instanceID) c.audio.Close(instanceID) c.packets.Close(instanceID) + c.notifyInstanceMediaCleanup(instanceID) c.runtimes.Remove(instanceID) c.incoming.Close(instanceID) } @@ -177,6 +195,7 @@ func (c *Coordinator) TerminateIncoming(ctx context.Context, instanceID, callID c.relays.Remove(instanceID, callID) c.audio.Remove(instanceID, callID) c.packets.Remove(instanceID, callID) + c.notifyCallMediaCleanup(instanceID, callID) return nil } @@ -189,11 +208,64 @@ func (c *Coordinator) FeedPCM(instanceID, callID string, pcm []float32) error { return c.audio.FeedPCM(instanceID, callID, pcm) } -// SetOnPCM registers the internal decoded-audio sink used by a future WebRTC, -// native playback or test bridge. The callback receives an owned PCM copy. +// SetOnPCM registers an optional external decoded-audio observer. Browser +// media keeps a separate internal sink so neither callback replaces the other. func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { - if c != nil { - c.audio.SetOnPCM(callback) + if c == nil { + return + } + c.mu.Lock() + c.onPCM = callback + c.mu.Unlock() +} + +func (c *Coordinator) SetBrowserPCM(callback func(instanceID, callID string, pcm []float32)) { + if c == nil { + return + } + c.mu.Lock() + c.browserPCM = callback + c.mu.Unlock() +} + +func (c *Coordinator) SetMediaCleanupHooks(onCall func(instanceID, callID string), onInstance func(instanceID string)) { + if c == nil { + return + } + c.mu.Lock() + c.onCallMediaCleanup = onCall + c.onInstanceMediaCleanup = onInstance + c.mu.Unlock() +} + +func (c *Coordinator) dispatchPCM(instanceID, callID string, pcm []float32) { + c.mu.RLock() + browserCallback := c.browserPCM + externalCallback := c.onPCM + c.mu.RUnlock() + if browserCallback != nil { + browserCallback(instanceID, callID, append([]float32(nil), pcm...)) + } + if externalCallback != nil { + externalCallback(instanceID, callID, append([]float32(nil), pcm...)) + } +} + +func (c *Coordinator) notifyCallMediaCleanup(instanceID, callID string) { + c.mu.RLock() + callback := c.onCallMediaCleanup + c.mu.RUnlock() + if callback != nil { + callback(instanceID, callID) + } +} + +func (c *Coordinator) notifyInstanceMediaCleanup(instanceID string) { + c.mu.RLock() + callback := c.onInstanceMediaCleanup + c.mu.RUnlock() + if callback != nil { + callback(instanceID) } } @@ -227,6 +299,7 @@ func (c *Coordinator) RemovePrivate(instanceID, callID string) { c.audio.Remove(instanceID, callID) c.packets.Remove(instanceID, callID) c.incoming.Remove(instanceID, callID) + c.notifyCallMediaCleanup(instanceID, callID) } // RemoveIncoming is kept as a compatibility alias while call-service code is From 0a1e60a8a22e5d02ff39563318466c8f53e2b37a Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:18:19 -0300 Subject: [PATCH 137/266] feat(call): notify browser media cleanup from relay lifecycle --- pkg/call/voip/media/relay_registry.go | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pkg/call/voip/media/relay_registry.go b/pkg/call/voip/media/relay_registry.go index 959a9269..7c1b2e93 100644 --- a/pkg/call/voip/media/relay_registry.go +++ b/pkg/call/voip/media/relay_registry.go @@ -41,6 +41,8 @@ type relaySession struct { ownJID func() types.JID onConnected func(instanceID, callID string) onPacket func(instanceID, callID string, packet []byte) + onRemoved func(instanceID, callID string) + onCleanup func(instanceID string) } func newRelaySession(instanceID string, client *whatsmeow.Client, source NegotiationSource, factory RelayFactory, log *slog.Logger) *relaySession { @@ -219,10 +221,14 @@ func (s *relaySession) remove(callID string) { relay := s.transports[callID] delete(s.transports, callID) delete(s.configuring, callID) + callback := s.onRemoved s.mu.Unlock() if relay != nil { relay.Cleanup() } + if callback != nil && callID != "" { + callback(s.instanceID, callID) + } } func (s *relaySession) cleanup() { @@ -233,10 +239,14 @@ func (s *relaySession) cleanup() { delete(s.transports, callID) } s.configuring = make(map[string]bool) + callback := s.onCleanup s.mu.Unlock() for _, relay := range transports { relay.Cleanup() } + if callback != nil { + callback(s.instanceID) + } } func (s *relaySession) close() { @@ -261,6 +271,8 @@ type RelayRegistry struct { sessions map[string]*relaySession onConnected func(instanceID, callID string) onPacket func(instanceID, callID string, packet []byte) + onRemoved func(instanceID, callID string) + onCleanup func(instanceID string) } func NewRelayRegistry(source NegotiationSource, factory RelayFactory, log *slog.Logger) *RelayRegistry { @@ -293,6 +305,24 @@ func (r *RelayRegistry) SetOnPacket(callback func(instanceID, callID string, pac r.mu.Unlock() } +func (r *RelayRegistry) SetOnRemoved(callback func(instanceID, callID string)) { + r.mu.Lock() + r.onRemoved = callback + for _, session := range r.sessions { + session.onRemoved = callback + } + r.mu.Unlock() +} + +func (r *RelayRegistry) SetOnCleanup(callback func(instanceID string)) { + r.mu.Lock() + r.onCleanup = callback + for _, session := range r.sessions { + session.onCleanup = callback + } + r.mu.Unlock() +} + func (r *RelayRegistry) Attach(instanceID string, client *whatsmeow.Client) { if instanceID == "" || client == nil { return @@ -309,6 +339,8 @@ func (r *RelayRegistry) Attach(instanceID string, client *whatsmeow.Client) { r.mu.Lock() candidate.onConnected = r.onConnected candidate.onPacket = r.onPacket + candidate.onRemoved = r.onRemoved + candidate.onCleanup = r.onCleanup previous := r.sessions[instanceID] r.sessions[instanceID] = candidate r.mu.Unlock() From 8ccf0edf6e8660fffd10753fa7002a6bb8e4b75b Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:19:01 -0300 Subject: [PATCH 138/266] feat(call): expose authenticated browser WebRTC sessions --- pkg/call/service/call_service.go | 43 +++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/pkg/call/service/call_service.go b/pkg/call/service/call_service.go index 942d58db..2e1243bf 100644 --- a/pkg/call/service/call_service.go +++ b/pkg/call/service/call_service.go @@ -8,6 +8,7 @@ import ( call_lifecycle "github.com/evolution-foundation/evolution-go/pkg/call/lifecycle" call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" + call_browser "github.com/evolution-foundation/evolution-go/pkg/call/voip/browser" call_driver "github.com/evolution-foundation/evolution-go/pkg/call/voip/driver" instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" logger_wrapper "github.com/evolution-foundation/evolution-go/pkg/logger" @@ -20,12 +21,17 @@ import ( const signalingTimeout = 30 * time.Second +var ErrCallNotActive = errors.New("call media is not active") + type CallService interface { StartCall(data *StartCallStruct, instance *instance_model.Instance) (call_runtime.Call, error) AcceptCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) TerminateCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) RejectCall(data *RejectCallStruct, instance *instance_model.Instance) error RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) + CreateWebRTC(ctx context.Context, callID string, request call_browser.CreateRequest, instance *instance_model.Instance) (call_browser.CreateResponse, error) + WebRTCSessions(callID string, instance *instance_model.Instance) ([]call_browser.SessionInfo, error) + CloseWebRTC(callID, sessionID string, instance *instance_model.Instance) error } type callService struct { @@ -33,6 +39,7 @@ type callService struct { whatsmeowService whatsmeow_service.WhatsmeowService loggerWrapper *logger_wrapper.LoggerManager coordinator *call_lifecycle.Coordinator + browser call_browser.Manager } type StartCallStruct struct { @@ -245,6 +252,36 @@ func (c *callService) RuntimeStatus(instance *instance_model.Instance) (call_run return runtime.Snapshot(), nil } +func (c *callService) CreateWebRTC(ctx context.Context, callID string, request call_browser.CreateRequest, instance *instance_model.Instance) (call_browser.CreateResponse, error) { + client, err := c.ensureClientConnected(instance.Id) + if err != nil { + return call_browser.CreateResponse{}, err + } + runtime := c.coordinator.RuntimeFor(instance.Id, client) + call, ok := runtime.Call(callID) + if !ok { + return call_browser.CreateResponse{}, fmt.Errorf("call %s not found", callID) + } + if call.State != call_runtime.StateActive { + return call_browser.CreateResponse{}, fmt.Errorf("%w: call %s is %s", ErrCallNotActive, callID, call.State) + } + return c.browser.Create(ctx, instance.Id, callID, request) +} + +func (c *callService) WebRTCSessions(callID string, instance *instance_model.Instance) ([]call_browser.SessionInfo, error) { + if callID == "" { + return nil, fmt.Errorf("callId is required") + } + return c.browser.Sessions(instance.Id, callID) +} + +func (c *callService) CloseWebRTC(callID, sessionID string, instance *instance_model.Instance) error { + if callID == "" || sessionID == "" { + return call_browser.ErrSessionNotFound + } + return c.browser.CloseSession(instance.Id, callID, sessionID) +} + func NewCallService( clientPointer map[string]*whatsmeow.Client, whatsmeowService whatsmeow_service.WhatsmeowService, @@ -254,10 +291,14 @@ func NewCallService( if coordinator == nil { coordinator = call_lifecycle.NewCoordinator() } - return &callService{ + service := &callService{ clientPointer: clientPointer, whatsmeowService: whatsmeowService, loggerWrapper: loggerWrapper, coordinator: coordinator, } + service.browser = call_browser.NewManager(coordinator.FeedPCM) + coordinator.SetBrowserPCM(service.browser.HandlePCM) + coordinator.SetMediaCleanupHooks(service.browser.CloseCall, service.browser.CloseInstance) + return service } From 11464d0adb3eb73d851799a7747dd9366daed674 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:19:35 -0300 Subject: [PATCH 139/266] feat(call): add browser WebRTC API handlers --- pkg/call/handler/call_handler.go | 109 +++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/pkg/call/handler/call_handler.go b/pkg/call/handler/call_handler.go index 84ca578a..812c8376 100644 --- a/pkg/call/handler/call_handler.go +++ b/pkg/call/handler/call_handler.go @@ -1,9 +1,13 @@ package call_handler import ( + "context" + "errors" "net/http" + "time" call_service "github.com/evolution-foundation/evolution-go/pkg/call/service" + call_browser "github.com/evolution-foundation/evolution-go/pkg/call/voip/browser" instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" "github.com/gin-gonic/gin" ) @@ -14,6 +18,9 @@ type CallHandler interface { TerminateCall(ctx *gin.Context) RejectCall(ctx *gin.Context) Status(ctx *gin.Context) + CreateWebRTC(ctx *gin.Context) + ListWebRTC(ctx *gin.Context) + CloseWebRTC(ctx *gin.Context) } type callHandler struct { @@ -35,8 +42,8 @@ func instanceFromContext(ctx *gin.Context) (*instance_model.Instance, bool) { } // Start call -// @Summary Start an experimental signaling-only WhatsApp call -// @Description Sends a real WhatsApp call offer. Audio transport is not implemented yet. +// @Summary Start an experimental WhatsApp call +// @Description Sends a real WhatsApp call offer and prepares the experimental media pipeline. // @Tags Call // @Accept json // @Produce json @@ -67,7 +74,7 @@ func (g *callHandler) StartCall(ctx *gin.Context) { // Accept call // @Summary Accept an incoming WhatsApp call -// @Description Sends preaccept and accept signaling for a prepared incoming call. Audio transport is not implemented yet. +// @Description Sends preaccept and accept signaling for a prepared incoming call. // @Tags Call // @Produce json // @Param callId path string true "Call ID" @@ -95,8 +102,8 @@ func (g *callHandler) AcceptCall(ctx *gin.Context) { } // Terminate call -// @Summary Terminate an outgoing WhatsApp call -// @Description Sends a terminate stanza for an outgoing call tracked by this instance +// @Summary Terminate a WhatsApp call +// @Description Sends a terminate stanza for a call tracked by this instance. // @Tags Call // @Produce json // @Param callId path string true "Call ID" @@ -176,6 +183,98 @@ func (g *callHandler) Status(ctx *gin.Context) { ctx.JSON(http.StatusOK, status) } +func browserHTTPStatus(err error) int { + switch { + case errors.Is(err, call_browser.ErrWebRTCDisabled): + return http.StatusNotImplemented + case errors.Is(err, call_browser.ErrInvalidOffer), errors.Is(err, call_browser.ErrInvalidPCMMessage): + return http.StatusBadRequest + case errors.Is(err, call_browser.ErrSessionNotFound): + return http.StatusNotFound + case errors.Is(err, call_browser.ErrSessionLimit), errors.Is(err, call_service.ErrCallNotActive): + return http.StatusConflict + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): + return http.StatusGatewayTimeout + default: + return http.StatusInternalServerError + } +} + +// Create browser WebRTC PCM session +// @Summary Create an experimental browser PCM bridge +// @Description Exchanges a complete SDP offer and answer. Requires the voip_pion build and an active WhatsApp call. +// @Tags Call +// @Accept json +// @Produce json +// @Param callId path string true "Call ID" +// @Param offer body call_browser.CreateRequest true "Browser SDP offer" +// @Success 201 {object} call_browser.CreateResponse +// @Router /call/{callId}/webrtc [post] +func (g *callHandler) CreateWebRTC(ctx *gin.Context) { + instance, ok := instanceFromContext(ctx) + if !ok { + return + } + callID := ctx.Param("callId") + if callID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "callId is required"}) + return + } + var request call_browser.CreateRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + requestContext, cancel := context.WithTimeout(ctx.Request.Context(), 30*time.Second) + defer cancel() + response, err := g.callService.CreateWebRTC(requestContext, callID, request, instance) + if err != nil { + ctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, response) +} + +// List browser WebRTC PCM sessions +// @Summary List browser PCM bridge sessions +// @Tags Call +// @Produce json +// @Param callId path string true "Call ID" +// @Success 200 {object} gin.H +// @Router /call/{callId}/webrtc [get] +func (g *callHandler) ListWebRTC(ctx *gin.Context) { + instance, ok := instanceFromContext(ctx) + if !ok { + return + } + sessions, err := g.callService.WebRTCSessions(ctx.Param("callId"), instance) + if err != nil { + ctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"sessions": sessions}) +} + +// Close browser WebRTC PCM session +// @Summary Close a browser PCM bridge session +// @Tags Call +// @Produce json +// @Param callId path string true "Call ID" +// @Param sessionId path string true "WebRTC session ID" +// @Success 200 {object} gin.H +// @Router /call/{callId}/webrtc/{sessionId} [delete] +func (g *callHandler) CloseWebRTC(ctx *gin.Context) { + instance, ok := instanceFromContext(ctx) + if !ok { + return + } + if err := g.callService.CloseWebRTC(ctx.Param("callId"), ctx.Param("sessionId"), instance); err != nil { + ctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"message": "browser media session closed"}) +} + func NewCallHandler(callService call_service.CallService) CallHandler { return &callHandler{callService: callService} } From 59cffc034899e41bcfca1d8a77bc3fa66ea9eacd Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:20:26 -0300 Subject: [PATCH 140/266] feat(call): register authenticated browser WebRTC routes --- pkg/routes/routes.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 036fbfa9..01e3c595 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -196,6 +196,9 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { routes.GET("/status", r.callHandler.Status) routes.POST("/start", r.callHandler.StartCall) routes.POST("/:callId/accept", r.callHandler.AcceptCall) + routes.POST("/:callId/webrtc", r.callHandler.CreateWebRTC) + routes.GET("/:callId/webrtc", r.callHandler.ListWebRTC) + routes.DELETE("/:callId/webrtc/:sessionId", r.callHandler.CloseWebRTC) routes.DELETE("/:callId", r.callHandler.TerminateCall) routes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall) } @@ -214,7 +217,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { routes.Use(r.authMiddleware.Auth) { routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.labelHandler.ChatLabel) - routes.POST("/message", r.labelHandler.MessageLabel) + routes.POST("/message", r.messageHandler.MessageLabel) routes.POST("/edit", r.labelHandler.EditLabel) routes.GET("/list", r.labelHandler.GetLabels) } @@ -232,23 +235,19 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { routes.Use(r.authMiddleware.Auth) { routes.POST("/create", r.newsletterHandler.CreateNewsletter) - routes.GET("/list", r.newsletterHandler.ListNewsletter) - routes.POST("/info", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.GetNewsletter) - routes.POST("/link", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.GetNewsletterInvite) - routes.POST("/subscribe", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.SubscribeNewsletter) - routes.POST("/messages", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.GetNewsletterMessages) + routes.GET("/list", r.newsletterHandler.ListNewsletters) + routes.POST("/info", r.newsletterHandler.GetNewsletterInfo) + routes.POST("/follow", r.newsletterHandler.FollowNewsletter) + routes.POST("/unfollow", r.newsletterHandler.UnfollowNewsletter) } } - - // NOVO: Rotas de Enquetes (Polls) - routes = eng.Group("/polls") + routes = eng.Group("/poll") { routes.Use(r.authMiddleware.Auth) { routes.GET("/:pollMessageId/results", r.pollHandler.GetPollResults) } } - } func NewRouter( From 844006064d2ed397d7d178bf70fb1a069106c95b Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:21:53 -0300 Subject: [PATCH 141/266] fix(call): preserve unrelated routes while adding WebRTC endpoints --- pkg/routes/routes.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 01e3c595..8d0f356b 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -217,7 +217,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { routes.Use(r.authMiddleware.Auth) { routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.labelHandler.ChatLabel) - routes.POST("/message", r.messageHandler.MessageLabel) + routes.POST("/message", r.labelHandler.MessageLabel) routes.POST("/edit", r.labelHandler.EditLabel) routes.GET("/list", r.labelHandler.GetLabels) } @@ -226,7 +226,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { { routes.Use(r.authMiddleware.Auth) { - routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.labelHandler.ChatUnlabel) + routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnlabel) routes.POST("/message", r.labelHandler.MessageUnlabel) } } @@ -235,19 +235,23 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { routes.Use(r.authMiddleware.Auth) { routes.POST("/create", r.newsletterHandler.CreateNewsletter) - routes.GET("/list", r.newsletterHandler.ListNewsletters) - routes.POST("/info", r.newsletterHandler.GetNewsletterInfo) - routes.POST("/follow", r.newsletterHandler.FollowNewsletter) - routes.POST("/unfollow", r.newsletterHandler.UnfollowNewsletter) + routes.GET("/list", r.newsletterHandler.ListNewsletter) + routes.POST("/info", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.GetNewsletter) + routes.POST("/link", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.GetNewsletterInvite) + routes.POST("/subscribe", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.SubscribeNewsletter) + routes.POST("/messages", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.GetNewsletterMessages) } } - routes = eng.Group("/poll") + + // NOVO: Rotas de Enquetes (Polls) + routes = eng.Group("/polls") { routes.Use(r.authMiddleware.Auth) { routes.GET("/:pollMessageId/results", r.pollHandler.GetPollResults) } } + } func NewRouter( From eaf7cc6eeb18e2e18510ab8a835a88aaf513994a Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:22:06 -0300 Subject: [PATCH 142/266] chore(call): remove browser bridge staging script --- tools/integrate_browser_webrtc.py | 1462 ----------------------------- 1 file changed, 1462 deletions(-) delete mode 100644 tools/integrate_browser_webrtc.py diff --git a/tools/integrate_browser_webrtc.py b/tools/integrate_browser_webrtc.py deleted file mode 100644 index e51a75d1..00000000 --- a/tools/integrate_browser_webrtc.py +++ /dev/null @@ -1,1462 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - - -def write(path: str, content: str) -> None: - target = ROOT / path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - - -def replace_once(path: str, old: str, new: str, label: str) -> None: - target = ROOT / path - text = target.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise RuntimeError(f"{label}: expected one match in {path}, found {count}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - -write("pkg/call/voip/browser/types.go", r'''package browser - -import ( - "context" - "errors" - "time" -) - -const ( - DataChannelLabel = "evolution-call-pcm" - DataChannelProtocol = "evcall.pcm.v1" - PCMSampleRate = 16000 - PCMChannels = 1 - PCMFrameSamples = 960 -) - -var ( - ErrWebRTCDisabled = errors.New("browser WebRTC bridge requires the voip_pion build tag") - ErrInvalidOffer = errors.New("invalid WebRTC SDP offer") - ErrSessionNotFound = errors.New("browser WebRTC session not found") - ErrSessionLimit = errors.New("browser WebRTC session limit reached") - ErrInvalidPCMMessage = errors.New("invalid browser PCM message") -) - -type SDPDescription struct { - Type string `json:"type" binding:"required"` - SDP string `json:"sdp" binding:"required"` -} - -type CreateRequest struct { - Offer SDPDescription `json:"offer" binding:"required"` -} - -type ProtocolInfo struct { - DataChannel string `json:"dataChannel"` - Protocol string `json:"protocol"` - Format string `json:"format"` - SampleRate int `json:"sampleRate"` - Channels int `json:"channels"` - FrameSamples int `json:"frameSamples"` -} - -type CreateResponse struct { - SessionID string `json:"sessionId"` - Answer SDPDescription `json:"answer"` - Audio ProtocolInfo `json:"audio"` -} - -type SessionState string - -const ( - SessionStateConnecting SessionState = "connecting" - SessionStateOpen SessionState = "open" - SessionStateClosed SessionState = "closed" - SessionStateFailed SessionState = "failed" -) - -type SessionInfo struct { - SessionID string `json:"sessionId"` - CallID string `json:"callId"` - State SessionState `json:"state"` - ChannelOpen bool `json:"channelOpen"` - CreatedAt time.Time `json:"createdAt"` - InputFrames uint64 `json:"inputFrames"` - OutputFrames uint64 `json:"outputFrames"` - DroppedFrames uint64 `json:"droppedFrames"` -} - -type PCMFeeder func(instanceID, callID string, pcm []float32) error - -type Manager interface { - Create(ctx context.Context, instanceID, callID string, request CreateRequest) (CreateResponse, error) - Sessions(instanceID, callID string) ([]SessionInfo, error) - CloseSession(instanceID, callID, sessionID string) error - CloseCall(instanceID, callID string) - CloseInstance(instanceID string) - HandlePCM(instanceID, callID string, pcm []float32) -} - -func DefaultProtocolInfo() ProtocolInfo { - return ProtocolInfo{ - DataChannel: DataChannelLabel, - Protocol: DataChannelProtocol, - Format: "f32le", - SampleRate: PCMSampleRate, - Channels: PCMChannels, - FrameSamples: PCMFrameSamples, - } -} -''') - -write("pkg/call/voip/browser/frame.go", r'''package browser - -import ( - "encoding/binary" - "fmt" - "math" -) - -const ( - pcmHeaderSize = 16 - pcmVersion = 1 - pcmKind = 1 - maxPCMSamples = PCMFrameSamples * 4 -) - -var pcmMagic = [4]byte{'E', 'V', 'P', 'C'} - -func EncodePCMFrame(pcm []float32) ([]byte, error) { - if len(pcm) == 0 || len(pcm) > maxPCMSamples { - return nil, fmt.Errorf("%w: sample count %d", ErrInvalidPCMMessage, len(pcm)) - } - output := make([]byte, pcmHeaderSize+len(pcm)*4) - copy(output[:4], pcmMagic[:]) - output[4] = pcmVersion - output[5] = pcmKind - binary.LittleEndian.PutUint16(output[6:8], 0) - binary.LittleEndian.PutUint32(output[8:12], PCMSampleRate) - binary.LittleEndian.PutUint32(output[12:16], uint32(len(pcm))) - offset := pcmHeaderSize - for _, sample := range pcm { - binary.LittleEndian.PutUint32(output[offset:offset+4], math.Float32bits(sample)) - offset += 4 - } - return output, nil -} - -func DecodePCMFrame(frame []byte) ([]float32, error) { - if len(frame) < pcmHeaderSize { - return nil, fmt.Errorf("%w: frame has %d bytes", ErrInvalidPCMMessage, len(frame)) - } - if string(frame[:4]) != string(pcmMagic[:]) || frame[4] != pcmVersion || frame[5] != pcmKind { - return nil, fmt.Errorf("%w: unsupported framing", ErrInvalidPCMMessage) - } - if binary.LittleEndian.Uint16(frame[6:8]) != 0 { - return nil, fmt.Errorf("%w: unsupported flags", ErrInvalidPCMMessage) - } - if binary.LittleEndian.Uint32(frame[8:12]) != PCMSampleRate { - return nil, fmt.Errorf("%w: sample rate must be %d", ErrInvalidPCMMessage, PCMSampleRate) - } - sampleCount := int(binary.LittleEndian.Uint32(frame[12:16])) - if sampleCount <= 0 || sampleCount > maxPCMSamples { - return nil, fmt.Errorf("%w: sample count %d", ErrInvalidPCMMessage, sampleCount) - } - expected := pcmHeaderSize + sampleCount*4 - if len(frame) != expected { - return nil, fmt.Errorf("%w: frame has %d bytes, want %d", ErrInvalidPCMMessage, len(frame), expected) - } - pcm := make([]float32, sampleCount) - offset := pcmHeaderSize - for index := range pcm { - pcm[index] = math.Float32frombits(binary.LittleEndian.Uint32(frame[offset : offset+4])) - offset += 4 - } - return pcm, nil -} - -func zeroPCM(values []float32) { - for index := range values { - values[index] = 0 - } -} - -func zeroFrame(value []byte) { - for index := range value { - value[index] = 0 - } -} -''') - -write("pkg/call/voip/browser/manager_default.go", r'''//go:build !voip_pion - -package browser - -import "context" - -type disabledManager struct{} - -func NewManager(PCMFeeder) Manager { - return &disabledManager{} -} - -func (*disabledManager) Create(context.Context, string, string, CreateRequest) (CreateResponse, error) { - return CreateResponse{}, ErrWebRTCDisabled -} - -func (*disabledManager) Sessions(string, string) ([]SessionInfo, error) { - return nil, ErrWebRTCDisabled -} - -func (*disabledManager) CloseSession(string, string, string) error { - return ErrWebRTCDisabled -} - -func (*disabledManager) CloseCall(string, string) {} -func (*disabledManager) CloseInstance(string) {} -func (*disabledManager) HandlePCM(string, string, []float32) {} -''') - -write("pkg/call/voip/browser/manager_pion.go", r'''//go:build voip_pion - -package browser - -import ( - "context" - "errors" - "fmt" - "sort" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/pion/webrtc/v4" -) - -const ( - maxSessionsPerCall = 4 - maxOfferBytes = 256 * 1024 - maxBufferedAmount = 512 * 1024 - mediaQueueDepth = 8 -) - -type pionManager struct { - mu sync.RWMutex - feeder PCMFeeder - sessions map[string]map[string]map[string]*pionSession -} - -type pionSession struct { - manager *pionManager - instanceID string - callID string - id string - createdAt time.Time - pc *webrtc.PeerConnection - - mu sync.RWMutex - channel *webrtc.DataChannel - state SessionState - inputFrames uint64 - outputFrames uint64 - droppedFrames uint64 - - incoming chan []float32 - outgoing chan []byte - stopCh chan struct{} - stopOnce sync.Once - wg sync.WaitGroup -} - -func NewManager(feeder PCMFeeder) Manager { - return &pionManager{ - feeder: feeder, - sessions: make(map[string]map[string]map[string]*pionSession), - } -} - -func (m *pionManager) Create(ctx context.Context, instanceID, callID string, request CreateRequest) (CreateResponse, error) { - if m == nil || instanceID == "" || callID == "" { - return CreateResponse{}, ErrInvalidOffer - } - offerType := strings.ToLower(strings.TrimSpace(request.Offer.Type)) - if offerType != "offer" || request.Offer.SDP == "" || len(request.Offer.SDP) > maxOfferBytes { - return CreateResponse{}, ErrInvalidOffer - } - - m.mu.Lock() - calls := m.sessions[instanceID] - if calls == nil { - calls = make(map[string]map[string]*pionSession) - m.sessions[instanceID] = calls - } - callSessions := calls[callID] - if callSessions == nil { - callSessions = make(map[string]*pionSession) - calls[callID] = callSessions - } - if len(callSessions) >= maxSessionsPerCall { - m.mu.Unlock() - return CreateResponse{}, ErrSessionLimit - } - sessionID := uuid.NewString() - m.mu.Unlock() - - pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) - if err != nil { - return CreateResponse{}, fmt.Errorf("create browser peer connection: %w", err) - } - session := &pionSession{ - manager: m, - instanceID: instanceID, - callID: callID, - id: sessionID, - createdAt: time.Now().UTC(), - pc: pc, - state: SessionStateConnecting, - incoming: make(chan []float32, mediaQueueDepth), - outgoing: make(chan []byte, mediaQueueDepth), - stopCh: make(chan struct{}), - } - session.wg.Add(2) - go session.inputLoop() - go session.outputLoop() - - m.mu.Lock() - calls = m.sessions[instanceID] - if calls == nil { - calls = make(map[string]map[string]*pionSession) - m.sessions[instanceID] = calls - } - callSessions = calls[callID] - if callSessions == nil { - callSessions = make(map[string]*pionSession) - calls[callID] = callSessions - } - if len(callSessions) >= maxSessionsPerCall { - m.mu.Unlock() - session.close() - return CreateResponse{}, ErrSessionLimit - } - callSessions[sessionID] = session - m.mu.Unlock() - - fail := func(cause error) (CreateResponse, error) { - _ = m.CloseSession(instanceID, callID, sessionID) - return CreateResponse{}, cause - } - - pc.OnDataChannel(func(channel *webrtc.DataChannel) { - session.attachDataChannel(channel) - }) - pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { - switch state { - case webrtc.PeerConnectionStateFailed: - session.setState(SessionStateFailed) - go func() { _ = m.CloseSession(instanceID, callID, sessionID) }() - case webrtc.PeerConnectionStateClosed: - go func() { _ = m.CloseSession(instanceID, callID, sessionID) }() - } - }) - - remote := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: request.Offer.SDP} - if err = pc.SetRemoteDescription(remote); err != nil { - return fail(fmt.Errorf("set browser remote description: %w", err)) - } - answer, err := pc.CreateAnswer(nil) - if err != nil { - return fail(fmt.Errorf("create browser SDP answer: %w", err)) - } - gatheringComplete := webrtc.GatheringCompletePromise(pc) - if err = pc.SetLocalDescription(answer); err != nil { - return fail(fmt.Errorf("set browser local description: %w", err)) - } - select { - case <-gatheringComplete: - case <-ctx.Done(): - return fail(fmt.Errorf("gather browser ICE candidates: %w", ctx.Err())) - } - local := pc.LocalDescription() - if local == nil || local.SDP == "" { - return fail(fmt.Errorf("create browser SDP answer: empty local description")) - } - - return CreateResponse{ - SessionID: sessionID, - Answer: SDPDescription{Type: "answer", SDP: local.SDP}, - Audio: DefaultProtocolInfo(), - }, nil -} - -func (m *pionManager) Sessions(instanceID, callID string) ([]SessionInfo, error) { - if m == nil { - return nil, ErrSessionNotFound - } - sessions := m.snapshot(instanceID, callID) - result := make([]SessionInfo, 0, len(sessions)) - for _, session := range sessions { - result = append(result, session.info()) - } - sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt.Before(result[j].CreatedAt) }) - return result, nil -} - -func (m *pionManager) CloseSession(instanceID, callID, sessionID string) error { - if m == nil || sessionID == "" { - return ErrSessionNotFound - } - m.mu.Lock() - calls := m.sessions[instanceID] - callSessions := calls[callID] - session := callSessions[sessionID] - if session != nil { - delete(callSessions, sessionID) - if len(callSessions) == 0 { - delete(calls, callID) - } - if len(calls) == 0 { - delete(m.sessions, instanceID) - } - } - m.mu.Unlock() - if session == nil { - return ErrSessionNotFound - } - session.close() - return nil -} - -func (m *pionManager) CloseCall(instanceID, callID string) { - for _, session := range m.takeCall(instanceID, callID) { - session.close() - } -} - -func (m *pionManager) CloseInstance(instanceID string) { - if m == nil { - return - } - m.mu.Lock() - calls := m.sessions[instanceID] - delete(m.sessions, instanceID) - m.mu.Unlock() - for _, callSessions := range calls { - for _, session := range callSessions { - session.close() - } - } -} - -func (m *pionManager) HandlePCM(instanceID, callID string, pcm []float32) { - if len(pcm) == 0 { - return - } - frame, err := EncodePCMFrame(pcm) - if err != nil { - return - } - defer zeroFrame(frame) - for _, session := range m.snapshot(instanceID, callID) { - session.enqueueOutgoing(append([]byte(nil), frame...)) - } -} - -func (m *pionManager) snapshot(instanceID, callID string) []*pionSession { - if m == nil { - return nil - } - m.mu.RLock() - callSessions := m.sessions[instanceID][callID] - result := make([]*pionSession, 0, len(callSessions)) - for _, session := range callSessions { - result = append(result, session) - } - m.mu.RUnlock() - return result -} - -func (m *pionManager) takeCall(instanceID, callID string) []*pionSession { - if m == nil { - return nil - } - m.mu.Lock() - calls := m.sessions[instanceID] - callSessions := calls[callID] - delete(calls, callID) - if len(calls) == 0 { - delete(m.sessions, instanceID) - } - result := make([]*pionSession, 0, len(callSessions)) - for _, session := range callSessions { - result = append(result, session) - } - m.mu.Unlock() - return result -} - -func (s *pionSession) attachDataChannel(channel *webrtc.DataChannel) { - if channel == nil || channel.Label() != DataChannelLabel { - if channel != nil { - _ = channel.Close() - } - s.incrementDropped() - return - } - if protocol := channel.Protocol(); protocol != "" && protocol != DataChannelProtocol { - _ = channel.Close() - s.incrementDropped() - return - } - - s.mu.Lock() - if s.channel != nil || s.state == SessionStateClosed || s.state == SessionStateFailed { - s.mu.Unlock() - _ = channel.Close() - return - } - s.channel = channel - s.mu.Unlock() - - channel.SetBufferedAmountLowThreshold(maxBufferedAmount / 2) - channel.OnOpen(func() { s.setState(SessionStateOpen) }) - channel.OnClose(func() { - go func() { _ = s.manager.CloseSession(s.instanceID, s.callID, s.id) }() - }) - channel.OnMessage(func(message webrtc.DataChannelMessage) { - if message.IsString { - s.incrementDropped() - return - } - pcm, err := DecodePCMFrame(message.Data) - if err != nil { - s.incrementDropped() - return - } - s.enqueueIncoming(pcm) - }) -} - -func (s *pionSession) enqueueIncoming(pcm []float32) { - select { - case <-s.stopCh: - zeroPCM(pcm) - case s.incoming <- pcm: - default: - zeroPCM(pcm) - s.incrementDropped() - } -} - -func (s *pionSession) enqueueOutgoing(frame []byte) { - if !s.isOpen() { - zeroFrame(frame) - s.incrementDropped() - return - } - select { - case <-s.stopCh: - zeroFrame(frame) - case s.outgoing <- frame: - default: - zeroFrame(frame) - s.incrementDropped() - } -} - -func (s *pionSession) inputLoop() { - defer s.wg.Done() - for { - select { - case <-s.stopCh: - return - case pcm := <-s.incoming: - if s.manager.feeder != nil { - if err := s.manager.feeder(s.instanceID, s.callID, pcm); err != nil { - s.incrementDropped() - } else { - s.mu.Lock() - s.inputFrames++ - s.mu.Unlock() - } - } else { - s.incrementDropped() - } - zeroPCM(pcm) - } - } -} - -func (s *pionSession) outputLoop() { - defer s.wg.Done() - for { - select { - case <-s.stopCh: - return - case frame := <-s.outgoing: - s.sendFrame(frame) - zeroFrame(frame) - } - } -} - -func (s *pionSession) sendFrame(frame []byte) { - s.mu.RLock() - channel := s.channel - open := s.state == SessionStateOpen && channel != nil - s.mu.RUnlock() - if !open || channel.BufferedAmount() > maxBufferedAmount { - s.incrementDropped() - return - } - if err := channel.Send(frame); err != nil { - s.incrementDropped() - return - } - s.mu.Lock() - s.outputFrames++ - s.mu.Unlock() -} - -func (s *pionSession) setState(state SessionState) { - s.mu.Lock() - if s.state != SessionStateClosed { - s.state = state - } - s.mu.Unlock() -} - -func (s *pionSession) isOpen() bool { - s.mu.RLock() - open := s.state == SessionStateOpen && s.channel != nil - s.mu.RUnlock() - return open -} - -func (s *pionSession) incrementDropped() { - s.mu.Lock() - s.droppedFrames++ - s.mu.Unlock() -} - -func (s *pionSession) info() SessionInfo { - s.mu.RLock() - info := SessionInfo{ - SessionID: s.id, - CallID: s.callID, - State: s.state, - ChannelOpen: s.state == SessionStateOpen && s.channel != nil, - CreatedAt: s.createdAt, - InputFrames: s.inputFrames, - OutputFrames: s.outputFrames, - DroppedFrames: s.droppedFrames, - } - s.mu.RUnlock() - return info -} - -func (s *pionSession) close() { - if s == nil { - return - } - s.stopOnce.Do(func() { - close(s.stopCh) - s.mu.Lock() - s.state = SessionStateClosed - channel := s.channel - s.channel = nil - pc := s.pc - s.pc = nil - s.mu.Unlock() - if channel != nil { - _ = channel.Close() - } - if pc != nil { - _ = pc.Close() - } - s.wg.Wait() - for { - select { - case pcm := <-s.incoming: - zeroPCM(pcm) - default: - goto outgoing - } - } - outgoing: - for { - select { - case frame := <-s.outgoing: - zeroFrame(frame) - default: - return - } - } - }) -} - -var _ Manager = (*pionManager)(nil) -var _ = errors.Is -''') - -write("pkg/call/voip/browser/frame_test.go", r'''package browser - -import ( - "errors" - "math" - "testing" -) - -func TestPCMFrameRoundTrip(t *testing.T) { - input := []float32{-1, -0.25, 0, 0.5, 1} - frame, err := EncodePCMFrame(input) - if err != nil { - t.Fatal(err) - } - output, err := DecodePCMFrame(frame) - if err != nil { - t.Fatal(err) - } - if len(output) != len(input) { - t.Fatalf("decoded %d samples, want %d", len(output), len(input)) - } - for index := range input { - if math.Float32bits(output[index]) != math.Float32bits(input[index]) { - t.Fatalf("sample %d=%v, want %v", index, output[index], input[index]) - } - } -} - -func TestPCMFrameRejectsMalformedInput(t *testing.T) { - frame, err := EncodePCMFrame(make([]float32, PCMFrameSamples)) - if err != nil { - t.Fatal(err) - } - cases := [][]byte{ - nil, - frame[:10], - append([]byte(nil), frame[:len(frame)-1]...), - append([]byte("BAD!"), frame[4:]...), - } - for _, value := range cases { - if _, err = DecodePCMFrame(value); !errors.Is(err, ErrInvalidPCMMessage) { - t.Fatalf("expected invalid PCM error, got %v", err) - } - } -} - -func TestPCMFrameLimitsSamples(t *testing.T) { - if _, err := EncodePCMFrame(nil); !errors.Is(err, ErrInvalidPCMMessage) { - t.Fatalf("expected empty frame error, got %v", err) - } - if _, err := EncodePCMFrame(make([]float32, maxPCMSamples+1)); !errors.Is(err, ErrInvalidPCMMessage) { - t.Fatalf("expected oversized frame error, got %v", err) - } -} -''') - -write("pkg/call/voip/browser/manager_default_test.go", r'''//go:build !voip_pion - -package browser - -import ( - "context" - "errors" - "testing" -) - -func TestDefaultManagerIsDisabled(t *testing.T) { - manager := NewManager(nil) - _, err := manager.Create(context.Background(), "instance", "call", CreateRequest{}) - if !errors.Is(err, ErrWebRTCDisabled) { - t.Fatalf("expected disabled error, got %v", err) - } -} -''') - -write("pkg/call/voip/browser/manager_pion_test.go", r'''//go:build voip_pion - -package browser - -import ( - "context" - "testing" - "time" - - "github.com/pion/webrtc/v4" -) - -func TestPionManagerBridgesPCMOverDataChannel(t *testing.T) { - fed := make(chan []float32, 2) - manager := NewManager(func(_, _ string, pcm []float32) error { - fed <- append([]float32(nil), pcm...) - return nil - }) - defer manager.CloseInstance("instance") - - client, err := webrtc.NewPeerConnection(webrtc.Configuration{}) - if err != nil { - t.Fatal(err) - } - defer client.Close() - protocol := DataChannelProtocol - channel, err := client.CreateDataChannel(DataChannelLabel, &webrtc.DataChannelInit{Protocol: &protocol}) - if err != nil { - t.Fatal(err) - } - opened := make(chan struct{}) - received := make(chan []float32, 2) - channel.OnOpen(func() { close(opened) }) - channel.OnMessage(func(message webrtc.DataChannelMessage) { - pcm, decodeErr := DecodePCMFrame(message.Data) - if decodeErr != nil { - t.Errorf("decode server PCM: %v", decodeErr) - return - } - received <- pcm - }) - - offer, err := client.CreateOffer(nil) - if err != nil { - t.Fatal(err) - } - gather := webrtc.GatheringCompletePromise(client) - if err = client.SetLocalDescription(offer); err != nil { - t.Fatal(err) - } - select { - case <-gather: - case <-time.After(10 * time.Second): - t.Fatal("client ICE gathering timed out") - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - response, err := manager.Create(ctx, "instance", "call", CreateRequest{Offer: SDPDescription{ - Type: "offer", - SDP: client.LocalDescription().SDP, - }}) - if err != nil { - t.Fatal(err) - } - if err = client.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: response.Answer.SDP}); err != nil { - t.Fatal(err) - } - select { - case <-opened: - case <-time.After(10 * time.Second): - t.Fatal("PCM data channel did not open") - } - - browserPCM := make([]float32, PCMFrameSamples) - browserPCM[0] = 0.25 - frame, err := EncodePCMFrame(browserPCM) - if err != nil { - t.Fatal(err) - } - if err = channel.Send(frame); err != nil { - t.Fatal(err) - } - select { - case got := <-fed: - if len(got) != PCMFrameSamples || got[0] != 0.25 { - t.Fatalf("unexpected fed PCM: len=%d first=%v", len(got), got[0]) - } - case <-time.After(10 * time.Second): - t.Fatal("server did not receive browser PCM") - } - - serverPCM := make([]float32, PCMFrameSamples) - serverPCM[0] = -0.5 - manager.HandlePCM("instance", "call", serverPCM) - select { - case got := <-received: - if len(got) != PCMFrameSamples || got[0] != -0.5 { - t.Fatalf("unexpected browser PCM: len=%d first=%v", len(got), got[0]) - } - case <-time.After(10 * time.Second): - t.Fatal("browser did not receive server PCM") - } - - sessions, err := manager.Sessions("instance", "call") - if err != nil || len(sessions) != 1 || !sessions[0].ChannelOpen { - t.Fatalf("unexpected sessions: %+v err=%v", sessions, err) - } - if err = manager.CloseSession("instance", "call", response.SessionID); err != nil { - t.Fatal(err) - } - sessions, err = manager.Sessions("instance", "call") - if err != nil || len(sessions) != 0 { - t.Fatalf("session was not removed: %+v err=%v", sessions, err) - } -} -''') - -# Coordinator: PCM fanout and media cleanup hooks. -replace_once( - "pkg/call/lifecycle/coordinator.go", - '''\taudio *call_media.AudioRegistry -\tonRTP func(instanceID, callID string, packet *call_media.RTPPacket) - -\tincomingEnabled map[string]bool -''', - '''\taudio *call_media.AudioRegistry -\tonRTP func(instanceID, callID string, packet *call_media.RTPPacket) -\tonPCM func(instanceID, callID string, pcm []float32) -\tbrowserPCM func(instanceID, callID string, pcm []float32) -\tonCallMediaCleanup func(instanceID, callID string) -\tonInstanceMediaCleanup func(instanceID string) - -\tincomingEnabled map[string]bool -''', - "coordinator callbacks", -) -replace_once( - "pkg/call/lifecycle/coordinator.go", - '''\tcoordinator.audio = call_media.NewAudioRegistry(func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { -\t\treturn coordinator.SendOpus(instanceID, callID, payload, durationSamples, marker) -\t}, nil) -\tcoordinator.relays = call_media.NewRelayRegistry(incoming, nil, nil) -''', - '''\tcoordinator.audio = call_media.NewAudioRegistry(func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { -\t\treturn coordinator.SendOpus(instanceID, callID, payload, durationSamples, marker) -\t}, nil) -\tcoordinator.audio.SetOnPCM(coordinator.dispatchPCM) -\tcoordinator.relays = call_media.NewRelayRegistry(incoming, nil, nil) -\tcoordinator.relays.SetOnRemoved(func(instanceID, callID string) { -\t\tcoordinator.audio.Remove(instanceID, callID) -\t\tcoordinator.packets.Remove(instanceID, callID) -\t\tcoordinator.notifyCallMediaCleanup(instanceID, callID) -\t}) -\tcoordinator.relays.SetOnCleanup(func(instanceID string) { -\t\tcoordinator.audio.Close(instanceID) -\t\tcoordinator.packets.Close(instanceID) -\t\tcoordinator.notifyInstanceMediaCleanup(instanceID) -\t}) -''', - "coordinator relay cleanup", -) -replace_once( - "pkg/call/lifecycle/coordinator.go", - '''\tc.relays.Close(instanceID) -\tc.audio.Close(instanceID) -\tc.packets.Close(instanceID) -''', - '''\tc.relays.Close(instanceID) -\tc.audio.Close(instanceID) -\tc.packets.Close(instanceID) -\tc.notifyInstanceMediaCleanup(instanceID) -''', - "coordinator detach cleanup", -) -replace_once( - "pkg/call/lifecycle/coordinator.go", - '''\tc.relays.Remove(instanceID, callID) -\tc.audio.Remove(instanceID, callID) -\tc.packets.Remove(instanceID, callID) -\treturn nil -''', - '''\tc.relays.Remove(instanceID, callID) -\tc.audio.Remove(instanceID, callID) -\tc.packets.Remove(instanceID, callID) -\tc.notifyCallMediaCleanup(instanceID, callID) -\treturn nil -''', - "coordinator terminate cleanup", -) -replace_once( - "pkg/call/lifecycle/coordinator.go", - '''// SetOnPCM registers the internal decoded-audio sink used by a future WebRTC, -// native playback or test bridge. The callback receives an owned PCM copy. -func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { -\tif c != nil { -\t\tc.audio.SetOnPCM(callback) -\t} -} -''', - '''// SetOnPCM registers an optional external decoded-audio observer. Browser -// media keeps a separate internal sink so neither callback replaces the other. -func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) { -\tif c == nil { -\t\treturn -\t} -\tc.mu.Lock() -\tc.onPCM = callback -\tc.mu.Unlock() -} - -func (c *Coordinator) SetBrowserPCM(callback func(instanceID, callID string, pcm []float32)) { -\tif c == nil { -\t\treturn -\t} -\tc.mu.Lock() -\tc.browserPCM = callback -\tc.mu.Unlock() -} - -func (c *Coordinator) SetMediaCleanupHooks(onCall func(instanceID, callID string), onInstance func(instanceID string)) { -\tif c == nil { -\t\treturn -\t} -\tc.mu.Lock() -\tc.onCallMediaCleanup = onCall -\tc.onInstanceMediaCleanup = onInstance -\tc.mu.Unlock() -} - -func (c *Coordinator) dispatchPCM(instanceID, callID string, pcm []float32) { -\tc.mu.RLock() -\tbrowserCallback := c.browserPCM -\texternalCallback := c.onPCM -\tc.mu.RUnlock() -\tif browserCallback != nil { -\t\tbrowserCallback(instanceID, callID, append([]float32(nil), pcm...)) -\t} -\tif externalCallback != nil { -\t\texternalCallback(instanceID, callID, append([]float32(nil), pcm...)) -\t} -} - -func (c *Coordinator) notifyCallMediaCleanup(instanceID, callID string) { -\tc.mu.RLock() -\tcallback := c.onCallMediaCleanup -\tc.mu.RUnlock() -\tif callback != nil { -\t\tcallback(instanceID, callID) -\t} -} - -func (c *Coordinator) notifyInstanceMediaCleanup(instanceID string) { -\tc.mu.RLock() -\tcallback := c.onInstanceMediaCleanup -\tc.mu.RUnlock() -\tif callback != nil { -\t\tcallback(instanceID) -\t} -} -''', - "coordinator PCM callbacks", -) -replace_once( - "pkg/call/lifecycle/coordinator.go", - '''\tc.relays.Remove(instanceID, callID) -\tc.audio.Remove(instanceID, callID) -\tc.packets.Remove(instanceID, callID) -\tc.incoming.Remove(instanceID, callID) -''', - '''\tc.relays.Remove(instanceID, callID) -\tc.audio.Remove(instanceID, callID) -\tc.packets.Remove(instanceID, callID) -\tc.incoming.Remove(instanceID, callID) -\tc.notifyCallMediaCleanup(instanceID, callID) -''', - "coordinator private cleanup", -) - -# Relay lifecycle cleanup notifications. -replace_once( - "pkg/call/voip/media/relay_registry.go", - '''\tonConnected func(instanceID, callID string) -\tonPacket func(instanceID, callID string, packet []byte) -}''', - '''\tonConnected func(instanceID, callID string) -\tonPacket func(instanceID, callID string, packet []byte) -\tonRemoved func(instanceID, callID string) -\tonCleanup func(instanceID string) -}''', - "relay session callbacks", -) -replace_once( - "pkg/call/voip/media/relay_registry.go", - '''func (s *relaySession) remove(callID string) { -\ts.mu.Lock() -\trelay := s.transports[callID] -\tdelete(s.transports, callID) -\tdelete(s.configuring, callID) -\ts.mu.Unlock() -\tif relay != nil { -\t\trelay.Cleanup() -\t} -}''', - '''func (s *relaySession) remove(callID string) { -\ts.mu.Lock() -\trelay := s.transports[callID] -\tdelete(s.transports, callID) -\tdelete(s.configuring, callID) -\tcallback := s.onRemoved -\ts.mu.Unlock() -\tif relay != nil { -\t\trelay.Cleanup() -\t} -\tif callback != nil && callID != "" { -\t\tcallback(s.instanceID, callID) -\t} -}''', - "relay remove callback", -) -replace_once( - "pkg/call/voip/media/relay_registry.go", - '''\ts.configuring = make(map[string]bool) -\ts.mu.Unlock() -\tfor _, relay := range transports { -\t\trelay.Cleanup() -\t} -}''', - '''\ts.configuring = make(map[string]bool) -\tcallback := s.onCleanup -\ts.mu.Unlock() -\tfor _, relay := range transports { -\t\trelay.Cleanup() -\t} -\tif callback != nil { -\t\tcallback(s.instanceID) -\t} -}''', - "relay cleanup callback", -) -replace_once( - "pkg/call/voip/media/relay_registry.go", - '''\tonConnected func(instanceID, callID string) -\tonPacket func(instanceID, callID string, packet []byte) -}''', - '''\tonConnected func(instanceID, callID string) -\tonPacket func(instanceID, callID string, packet []byte) -\tonRemoved func(instanceID, callID string) -\tonCleanup func(instanceID string) -}''', - "relay registry callbacks", -) -replace_once( - "pkg/call/voip/media/relay_registry.go", - '''func (r *RelayRegistry) SetOnPacket(callback func(instanceID, callID string, packet []byte)) { -\tr.mu.Lock() -\tr.onPacket = callback -\tfor _, session := range r.sessions { -\t\tsession.onPacket = callback -\t} -\tr.mu.Unlock() -} -''', - '''func (r *RelayRegistry) SetOnPacket(callback func(instanceID, callID string, packet []byte)) { -\tr.mu.Lock() -\tr.onPacket = callback -\tfor _, session := range r.sessions { -\t\tsession.onPacket = callback -\t} -\tr.mu.Unlock() -} - -func (r *RelayRegistry) SetOnRemoved(callback func(instanceID, callID string)) { -\tr.mu.Lock() -\tr.onRemoved = callback -\tfor _, session := range r.sessions { -\t\tsession.onRemoved = callback -\t} -\tr.mu.Unlock() -} - -func (r *RelayRegistry) SetOnCleanup(callback func(instanceID string)) { -\tr.mu.Lock() -\tr.onCleanup = callback -\tfor _, session := range r.sessions { -\t\tsession.onCleanup = callback -\t} -\tr.mu.Unlock() -} -''', - "relay setters", -) -replace_once( - "pkg/call/voip/media/relay_registry.go", - '''\tcandidate.onConnected = r.onConnected -\tcandidate.onPacket = r.onPacket -''', - '''\tcandidate.onConnected = r.onConnected -\tcandidate.onPacket = r.onPacket -\tcandidate.onRemoved = r.onRemoved -\tcandidate.onCleanup = r.onCleanup -''', - "relay attach callbacks", -) - -# Call service browser API. -replace_once( - "pkg/call/service/call_service.go", - '''\tcall_driver "github.com/evolution-foundation/evolution-go/pkg/call/voip/driver" -''', - '''\tcall_browser "github.com/evolution-foundation/evolution-go/pkg/call/voip/browser" -\tcall_driver "github.com/evolution-foundation/evolution-go/pkg/call/voip/driver" -''', - "service browser import", -) -replace_once( - "pkg/call/service/call_service.go", - '''const signalingTimeout = 30 * time.Second -''', - '''const signalingTimeout = 30 * time.Second - -var ErrCallNotActive = errors.New("call media is not active") -''', - "service active error", -) -replace_once( - "pkg/call/service/call_service.go", - '''\tRejectCall(data *RejectCallStruct, instance *instance_model.Instance) error -\tRuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) -}''', - '''\tRejectCall(data *RejectCallStruct, instance *instance_model.Instance) error -\tRuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) -\tCreateWebRTC(ctx context.Context, callID string, request call_browser.CreateRequest, instance *instance_model.Instance) (call_browser.CreateResponse, error) -\tWebRTCSessions(callID string, instance *instance_model.Instance) ([]call_browser.SessionInfo, error) -\tCloseWebRTC(callID, sessionID string, instance *instance_model.Instance) error -}''', - "service interface", -) -replace_once( - "pkg/call/service/call_service.go", - '''\tloggerWrapper *logger_wrapper.LoggerManager -\tcoordinator *call_lifecycle.Coordinator -}''', - '''\tloggerWrapper *logger_wrapper.LoggerManager -\tcoordinator *call_lifecycle.Coordinator -\tbrowser call_browser.Manager -}''', - "service browser field", -) -replace_once( - "pkg/call/service/call_service.go", - '''func (c *callService) RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) { -\tclient, err := c.ensureClientConnected(instance.Id) -\tif err != nil { -\t\treturn call_runtime.Snapshot{InstanceID: instance.Id}, err -\t} - -\truntime := c.coordinator.RuntimeFor(instance.Id, client) -\treturn runtime.Snapshot(), nil -} -''', - '''func (c *callService) RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) { -\tclient, err := c.ensureClientConnected(instance.Id) -\tif err != nil { -\t\treturn call_runtime.Snapshot{InstanceID: instance.Id}, err -\t} - -\truntime := c.coordinator.RuntimeFor(instance.Id, client) -\treturn runtime.Snapshot(), nil -} - -func (c *callService) CreateWebRTC(ctx context.Context, callID string, request call_browser.CreateRequest, instance *instance_model.Instance) (call_browser.CreateResponse, error) { -\tclient, err := c.ensureClientConnected(instance.Id) -\tif err != nil { -\t\treturn call_browser.CreateResponse{}, err -\t} -\truntime := c.coordinator.RuntimeFor(instance.Id, client) -\tcall, ok := runtime.Call(callID) -\tif !ok { -\t\treturn call_browser.CreateResponse{}, fmt.Errorf("call %s not found", callID) -\t} -\tif call.State != call_runtime.StateActive { -\t\treturn call_browser.CreateResponse{}, fmt.Errorf("%w: call %s is %s", ErrCallNotActive, callID, call.State) -\t} -\treturn c.browser.Create(ctx, instance.Id, callID, request) -} - -func (c *callService) WebRTCSessions(callID string, instance *instance_model.Instance) ([]call_browser.SessionInfo, error) { -\tif callID == "" { -\t\treturn nil, fmt.Errorf("callId is required") -\t} -\treturn c.browser.Sessions(instance.Id, callID) -} - -func (c *callService) CloseWebRTC(callID, sessionID string, instance *instance_model.Instance) error { -\tif callID == "" || sessionID == "" { -\t\treturn call_browser.ErrSessionNotFound -\t} -\treturn c.browser.CloseSession(instance.Id, callID, sessionID) -} -''', - "service browser methods", -) -replace_once( - "pkg/call/service/call_service.go", - '''\treturn &callService{ -\t\tclientPointer: clientPointer, -\t\twhatsmeowService: whatsmeowService, -\t\tloggerWrapper: loggerWrapper, -\t\tcoordinator: coordinator, -\t} -}''', - '''\tservice := &callService{ -\t\tclientPointer: clientPointer, -\t\twhatsmeowService: whatsmeowService, -\t\tloggerWrapper: loggerWrapper, -\t\tcoordinator: coordinator, -\t} -\tservice.browser = call_browser.NewManager(coordinator.FeedPCM) -\tcoordinator.SetBrowserPCM(service.browser.HandlePCM) -\tcoordinator.SetMediaCleanupHooks(service.browser.CloseCall, service.browser.CloseInstance) -\treturn service -}''', - "service constructor", -) - -# Handler endpoints and status mapping. -replace_once( - "pkg/call/handler/call_handler.go", - '''import ( -\t"net/http" - -\tcall_service "github.com/evolution-foundation/evolution-go/pkg/call/service" -''', - '''import ( -\t"context" -\t"errors" -\t"net/http" -\t"time" - -\tcall_service "github.com/evolution-foundation/evolution-go/pkg/call/service" -\tcall_browser "github.com/evolution-foundation/evolution-go/pkg/call/voip/browser" -''', - "handler imports", -) -replace_once( - "pkg/call/handler/call_handler.go", - '''\tRejectCall(ctx *gin.Context) -\tStatus(ctx *gin.Context) -}''', - '''\tRejectCall(ctx *gin.Context) -\tStatus(ctx *gin.Context) -\tCreateWebRTC(ctx *gin.Context) -\tListWebRTC(ctx *gin.Context) -\tCloseWebRTC(ctx *gin.Context) -}''', - "handler interface", -) -replace_once( - "pkg/call/handler/call_handler.go", - '''func NewCallHandler(callService call_service.CallService) CallHandler { -\treturn &callHandler{callService: callService} -} -''', - '''func browserHTTPStatus(err error) int { -\tswitch { -\tcase errors.Is(err, call_browser.ErrWebRTCDisabled): -\t\treturn http.StatusNotImplemented -\tcase errors.Is(err, call_browser.ErrInvalidOffer), errors.Is(err, call_browser.ErrInvalidPCMMessage): -\t\treturn http.StatusBadRequest -\tcase errors.Is(err, call_browser.ErrSessionNotFound): -\t\treturn http.StatusNotFound -\tcase errors.Is(err, call_browser.ErrSessionLimit), errors.Is(err, call_service.ErrCallNotActive): -\t\treturn http.StatusConflict -\tcase errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): -\t\treturn http.StatusGatewayTimeout -\tdefault: -\t\treturn http.StatusInternalServerError -\t} -} - -// Create browser WebRTC PCM session -// @Summary Create an experimental browser PCM bridge -// @Description Exchanges a complete SDP offer/answer. Requires the voip_pion build and an active WhatsApp call. -// @Tags Call -// @Accept json -// @Produce json -// @Param callId path string true "Call ID" -// @Param offer body call_browser.CreateRequest true "Browser SDP offer" -// @Success 201 {object} call_browser.CreateResponse -// @Router /call/{callId}/webrtc [post] -func (g *callHandler) CreateWebRTC(ctx *gin.Context) { -\tinstance, ok := instanceFromContext(ctx) -\tif !ok { -\t\treturn -\t} -\tcallID := ctx.Param("callId") -\tif callID == "" { -\t\tctx.JSON(http.StatusBadRequest, gin.H{"error": "callId is required"}) -\t\treturn -\t} -\tvar request call_browser.CreateRequest -\tif err := ctx.ShouldBindJSON(&request); err != nil { -\t\tctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -\t\treturn -\t} -\trequestContext, cancel := context.WithTimeout(ctx.Request.Context(), 30*time.Second) -\tdefer cancel() -\tresponse, err := g.callService.CreateWebRTC(requestContext, callID, request, instance) -\tif err != nil { -\t\tctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()}) -\t\treturn -\t} -\tctx.JSON(http.StatusCreated, response) -} - -// List browser WebRTC PCM sessions -// @Summary List browser PCM bridge sessions -// @Tags Call -// @Produce json -// @Param callId path string true "Call ID" -// @Success 200 {object} gin.H -// @Router /call/{callId}/webrtc [get] -func (g *callHandler) ListWebRTC(ctx *gin.Context) { -\tinstance, ok := instanceFromContext(ctx) -\tif !ok { -\t\treturn -\t} -\tsessions, err := g.callService.WebRTCSessions(ctx.Param("callId"), instance) -\tif err != nil { -\t\tctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()}) -\t\treturn -\t} -\tctx.JSON(http.StatusOK, gin.H{"sessions": sessions}) -} - -// Close browser WebRTC PCM session -// @Summary Close a browser PCM bridge session -// @Tags Call -// @Produce json -// @Param callId path string true "Call ID" -// @Param sessionId path string true "WebRTC session ID" -// @Success 200 {object} gin.H -// @Router /call/{callId}/webrtc/{sessionId} [delete] -func (g *callHandler) CloseWebRTC(ctx *gin.Context) { -\tinstance, ok := instanceFromContext(ctx) -\tif !ok { -\t\treturn -\t} -\tif err := g.callService.CloseWebRTC(ctx.Param("callId"), ctx.Param("sessionId"), instance); err != nil { -\t\tctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()}) -\t\treturn -\t} -\tctx.JSON(http.StatusOK, gin.H{"message": "browser media session closed"}) -} - -func NewCallHandler(callService call_service.CallService) CallHandler { -\treturn &callHandler{callService: callService} -} -''', - "handler browser methods", -) - -# Routes. -replace_once( - "pkg/routes/routes.go", - '''\t\troutes.POST("/:callId/accept", r.callHandler.AcceptCall) -\t\troutes.DELETE("/:callId", r.callHandler.TerminateCall) -\t\troutes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall) -''', - '''\t\troutes.POST("/:callId/accept", r.callHandler.AcceptCall) -\t\troutes.POST("/:callId/webrtc", r.callHandler.CreateWebRTC) -\t\troutes.GET("/:callId/webrtc", r.callHandler.ListWebRTC) -\t\troutes.DELETE("/:callId/webrtc/:sessionId", r.callHandler.CloseWebRTC) -\t\troutes.DELETE("/:callId", r.callHandler.TerminateCall) -\t\troutes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall) -''', - "browser routes", -) - -# Remove migration artifacts from the commit produced by the workflow. -for relative in ["tools/integrate_browser_webrtc.py", ".github/workflows/integrate-browser-webrtc.yml"]: - target = ROOT / relative - if target.exists(): - target.unlink() From 7667ee817eb2e5fd68ba4ccfaf7b17783ee87efc Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:22:12 -0300 Subject: [PATCH 143/266] chore(call): remove browser bridge staging workflow --- .../workflows/integrate-browser-webrtc.yml | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/integrate-browser-webrtc.yml diff --git a/.github/workflows/integrate-browser-webrtc.yml b/.github/workflows/integrate-browser-webrtc.yml deleted file mode 100644 index 01b2af58..00000000 --- a/.github/workflows/integrate-browser-webrtc.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Integrate browser WebRTC PCM - -on: - push: - branches: - - dev/astracalls-integration - paths: - - tools/integrate_browser_webrtc.py - - .github/workflows/integrate-browser-webrtc.yml - -permissions: - contents: write - -jobs: - integrate: - runs-on: ubuntu-latest - steps: - - name: Checkout integration branch - uses: actions/checkout@v4 - with: - ref: dev/astracalls-integration - fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - name: Apply browser bridge migration - run: python3 tools/integrate_browser_webrtc.py - - name: Format implementation - run: gofmt -w pkg/call/voip/browser pkg/call/lifecycle/coordinator.go pkg/call/voip/media/relay_registry.go pkg/call/service/call_service.go pkg/call/handler/call_handler.go pkg/routes/routes.go - - name: Test default call build - run: go test -race ./pkg/call/... - - name: Test experimental Pion build - run: go test -race -tags=voip_pion ./pkg/call/... - - name: Commit browser bridge - env: - GH_TOKEN: ${{ github.token }} - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "feat(call): add authenticated browser WebRTC PCM bridge" - git push origin HEAD:dev/astracalls-integration From dc64876f01db7323a607107d7be2d913c643a462 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:23:45 -0300 Subject: [PATCH 144/266] docs(call): add browser microphone and playback example --- docs/examples/call-webrtc-pcm.html | 335 +++++++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 docs/examples/call-webrtc-pcm.html diff --git a/docs/examples/call-webrtc-pcm.html b/docs/examples/call-webrtc-pcm.html new file mode 100644 index 00000000..65c6ecac --- /dev/null +++ b/docs/examples/call-webrtc-pcm.html @@ -0,0 +1,335 @@ + + + + + + Evolution Go — chamada WebRTC PCM + + + +

Chamada WebRTC PCM

+

Exemplo experimental. Use HTTPS ou localhost para liberar o microfone. A chamada do WhatsApp precisa estar no estado active e o servidor deve ser compilado com -tags=voip_pion.

+ +
+ + + +
+ + + +
+
+ +

Estado

+

+
+
+
+

From 81c8137020eb6aa3ebf3ccca44d5e74b8979d919 Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 19:24:38 -0300
Subject: [PATCH 145/266] docs(call): document authenticated browser WebRTC PCM
 bridge

---
 docs/wiki/guias-api/api-calls-experimental.md | 286 ++++++++++--------
 1 file changed, 165 insertions(+), 121 deletions(-)

diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md
index 7f98c57f..3fb79723 100644
--- a/docs/wiki/guias-api/api-calls-experimental.md
+++ b/docs/wiki/guias-api/api-calls-experimental.md
@@ -2,7 +2,7 @@
 
 Esta branch adiciona a integração experimental WaCalls/AstraCalls ao Evolution Go.
 
-> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante `voip_pion` negocia DataChannels com os relays, processa RTP/SRTP autenticado e possui codec MLow, jitter buffer e entrada/saída PCM internas. Ainda não há áudio audível para o usuário porque microfone, reprodução e ponte WebRTC não foram conectados. Não use em produção.
+> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante `voip_pion` negocia DataChannels com os relays do WhatsApp, processa RTP/SRTP autenticado, usa codec MLow com jitter/PLC e oferece uma ponte WebRTC PCM para microfone e reprodução no navegador. A conexão com relays reais ainda precisa de validação ponta a ponta. Não use em produção.
 
 Todas as rotas usam a autenticação normal da instância do Evolution.
 
@@ -10,9 +10,10 @@ Todas as rotas usam a autenticação normal da instância do Evolution.
 
 ```http
 GET /call/status
+apikey: INSTANCE_TOKEN
 ```
 
-Os monitores são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels, sessões RTP/SRTP, jitter buffers, codecs e material privado são removidos antes que o novo cliente seja registrado. Não é necessário chamar `/call/status` para ativar o monitoramento.
+Os monitores são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels, sessões RTP/SRTP, jitter buffers, codecs, sessões WebRTC e material privado são removidos antes que o novo cliente seja registrado.
 
 Exemplo de resposta:
 
@@ -24,9 +25,7 @@ Exemplo de resposta:
 }
 ```
 
-Chaves de chamada, JIDs internos de dispositivos, chaves SRTP, tokens de relay, pacotes enfileirados e buffers PCM não fazem parte dessa resposta. Eles ficam somente em memória e são apagados quando a chamada termina, é rejeitada ou a sessão é desconectada.
-
-Instâncias configuradas com rejeição automática continuam rastreando o estado público da chamada. Elas não descriptografam ofertas recebidas nem enviam `preaccept`, mas continuam podendo iniciar chamadas e armazenar sua negociação privada de saída.
+Chaves de chamada, JIDs internos de dispositivos, chaves SRTP, tokens de relay, pacotes enfileirados e buffers PCM nunca fazem parte dessa resposta.
 
 ## Iniciar uma chamada
 
@@ -43,7 +42,7 @@ apikey: INSTANCE_TOKEN
 }
 ```
 
-A oferta é enviada como uma consulta do protocolo. Quando o ACK contém relays estruturados, a chave gerada, os participantes e os candidatos são copiados para o registro privado da chamada antes de o resultado transitório ser sobrescrito.
+A oferta é enviada como uma consulta do protocolo. Quando o ACK contém relays estruturados, a chave gerada, os participantes e os candidatos são copiados para o registro privado da chamada.
 
 A resposta HTTP `201` contém somente o estado público:
 
@@ -61,8 +60,6 @@ A resposta HTTP `201` contém somente o estado público:
 
 ## Aceitar uma chamada recebida
 
-Quando uma chamada recebida aparecer em `GET /call/status`, use:
-
 ```http
 POST /call/{callId}/accept
 apikey: INSTANCE_TOKEN
@@ -70,32 +67,18 @@ apikey: INSTANCE_TOKEN
 
 O runtime descriptografa a chave recebida usando a sessão Signal já autenticada, envia `preaccept` automaticamente e mantém o material somente na memória privada. O endpoint envia a stanza `accept` e retorna a chamada no estado `connecting`.
 
-```json
-{
-  "id": "CALL_ID",
-  "peer": "5511999999999@s.whatsapp.net",
-  "direction": "incoming",
-  "state": "connecting",
-  "video": false
-}
-```
-
-`CallAccept` não marca a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que o DataChannel abre e as sessões RTP/SRTP, jitter e MLow são criadas com sucesso.
+`CallAccept` não marca a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que o relay abre e as sessões RTP/SRTP, jitter e MLow são criadas com sucesso.
 
-Se a preparação criptográfica do evento ainda não terminou, a API retorna um erro informando que a chamada ainda não está pronta para aceite.
-
-## Encerrar uma chamada
+## Encerrar ou rejeitar
 
 ```http
 DELETE /call/{callId}
 apikey: INSTANCE_TOKEN
 ```
 
-A rota envia `terminate` para chamadas realizadas e recebidas. Em seguida, o runtime muda o estado público para `ended` e remove chave, candidatos, DataChannels, contextos RTP/SRTP, jitter buffer, codec, buffers PCM e demais dados privados da chamada.
-
-## Rejeitar uma chamada recebida
+A rota envia `terminate`, muda o estado público para `ended` e remove relays, contextos criptográficos, codec, jitter, buffers PCM e sessões WebRTC ligadas à chamada.
 
-A rota existente foi preservada:
+A rota de rejeição existente foi preservada:
 
 ```http
 POST /call/reject
@@ -112,147 +95,208 @@ apikey: INSTANCE_TOKEN
 
 ## Estados rastreados
 
-O snapshot público usa:
-
-- `ringing`
-- `connecting`
-- `active`
-- `ended`
-- `failed`
-
-Internamente, a negociação usa uma máquina de estados estrita com:
-
-- `initiating`
-- `ringing`
-- `incoming_ringing`
-- `connecting`
-- `active`
-- `on_hold`
-- `ended`
+O snapshot público usa `ringing`, `connecting`, `active`, `ended` e `failed`.
 
-Transições inválidas, como marcar mídia conectada antes do aceite ou aceitar remotamente uma chamada recebida, são rejeitadas sem alterar o estado.
+Internamente, a negociação usa uma máquina estrita com `initiating`, `ringing`, `incoming_ringing`, `connecting`, `active`, `on_hold` e `ended`. Transições inválidas são rejeitadas sem alterar o estado.
 
 ## Relay e transporte Pion
 
-O módulo de sinalização reconhece os dois formatos encontrados nas respostas do WhatsApp:
+O módulo reconhece candidatos com atributos diretos e respostas estruturadas `te2`. Os candidatos são ordenados pelo menor RTT e associados ao material privado pelo `callId`.
 
-- candidatos com atributos diretos, como `ip`, `port`, `token`, `relay-id` e `c2r-rtt`;
-- respostas estruturadas `te2`, com tokens binários, `auth_token`, participantes, UUID, PIDs, HBH key, protocolo e endereço codificado em seis bytes.
-
-Os candidatos são ordenados pelo menor RTT e associados ao material privado pelo `callId`. Atualizações posteriores recebidas em `CallTransport` substituem os candidatos anteriores sem apagar a chave da chamada.
-
-A implementação experimental Pion inclui:
+A implementação Pion experimental inclui:
 
 - PeerConnection e DataChannel `wa-web-call` por relay;
-- transformação do SDP para credenciais e fingerprint do relay;
+- transformação do SDP para credenciais e fingerprint do WhatsApp;
 - registro STUN com subscriptions de SSRC;
-- requisição de allocation;
-- tentativas adicionais de registro;
-- keepalive proprietário do WhatsApp;
-- broadcast e recebimento de frames do DataChannel;
+- allocation, retries e keepalive;
+- broadcast e recebimento de frames;
 - timeout, fechamento e limpeza de buffers;
-- SSRC determinístico derivado de `callId` e JID do dispositivo.
+- SSRC determinístico por `callId` e JID de dispositivo.
 
 ## RTP e SRTP
 
 O caminho de pacotes inclui:
 
 - RTP versão 2 com CSRC, extensões e padding validados;
-- gerador concorrente de sequência e timestamp para payload type `120`;
-- derivação HKDF-SHA256 por JID de dispositivo a partir da chave privada de 32 bytes da chamada;
-- chave mestra AES de 16 bytes e salt de 14 bytes;
-- AES-CTR para proteção do payload;
-- autenticação HMAC-SHA1 truncada em quatro bytes;
-- verificação da autenticação antes da descriptografia;
-- rollover counter para a transição de sequência `65535 → 0`;
+- payload type `120`;
+- derivação HKDF-SHA256 por dispositivo;
+- AES-CTR e HMAC-SHA1 truncado;
+- autenticação verificada antes da descriptografia;
+- rollover counter na transição `65535 → 0`;
 - janela antirreplay de 64 pacotes;
-- suporte a pacotes autenticados fora de ordem dentro da janela;
-- rejeição de reutilização do índice SRTP no envio;
-- validação do SSRC remoto e do payload type de áudio;
+- pacotes autenticados fora de ordem;
+- rejeição de reutilização do índice de envio;
 - sessão independente por `callId`;
 - limpeza sincronizada durante término, rejeição, logout ou reconexão.
 
 ## Jitter buffer e perda de pacotes
 
-Cada chamada possui um jitter buffer independente antes do decoder MLow. A configuração padrão atual é fixa:
+Cada chamada possui um jitter buffer antes do decoder MLow. A configuração padrão atual é fixa:
 
-- duração de frame de 60 ms;
+- frames de 60 ms;
 - atraso inicial de dois pacotes, aproximadamente 120 ms;
 - limite de 64 pacotes enfileirados;
-- no máximo cinco frames consecutivos de concealment por lacuna.
-
-O buffer:
-
-- usa número de sequência estendido para ordenar pacotes durante o rollover `65535 → 0`;
-- aceita pacotes fora de ordem que ainda não perderam o prazo de reprodução;
-- rejeita duplicatas, pacotes atrasados e estouro do limite sem derrubar a chamada;
-- mantém contadores internos de recebidos, entregues, ocultados, duplicados, atrasados e descartados por limite;
-- gera PLC chamando `Decode(nil)` somente quando um pacote futuro confirma uma lacuna;
-- limita o PLC consecutivo e depois sincroniza novamente no próximo pacote disponível;
-- não fabrica áudio no final do fluxo quando não existe pacote futuro;
-- copia e apaga os payloads privados que mantém na fila;
-- encerra o relógio de playout antes de destruir o codec.
+- até cinco frames consecutivos de concealment por lacuna.
 
-Esta primeira versão não adapta automaticamente o atraso à variação observada da rede. A adaptação dinâmica será feita depois da validação com relays reais.
+O buffer usa sequência estendida para rollover, aceita pacotes fora de ordem antes do prazo, contabiliza duplicatas/atrasados/overflow e chama `Decode(nil)` somente quando um pacote futuro confirma a lacuna. Ele não fabrica áudio ao final do fluxo.
 
 ## Codec MLow e PCM
 
-O codec MLow em Go puro foi portado da revisão MIT fixada `edeb31f0427aba896639db503153b777a405eccf` do WaCalls. O runtime não depende de CGO ou de uma instalação externa de `libopus` para essa etapa.
+O codec MLow em Go puro foi portado da revisão MIT fixa `edeb31f0427aba896639db503153b777a405eccf` do WaCalls. Não há dependência de CGO ou `libopus`.
 
-O pipeline interno agora:
+O pipeline:
 
 - aceita PCM mono `float32` em 16 kHz;
-- aceita chunks de tamanho arbitrário;
-- acumula frames completos de 960 amostras, equivalentes a 60 ms;
-- substitui `NaN` e infinito por silêncio;
-- limita amplitudes ao intervalo `[-1, 1]`;
-- codifica MLow e envia o payload pelo RTP/SRTP existente;
-- preserva o marker RTP no primeiro frame transmitido;
-- envia frames de silêncio quando a captura fica inativa;
-- entrega RTP recebido ao jitter buffer antes da decodificação;
-- decodifica payloads ordenados ou PLC para blocos PCM de 960 amostras;
-- entrega uma cópia do PCM a um callback interno;
-- serializa encoder e decoder por chamada;
-- espera envios e playout em andamento antes do teardown.
-
-As fronteiras internas disponíveis no `Coordinator` são:
-
-- `FeedPCM(instanceID, callID, pcm)` para PCM mono/16 kHz;
-- `SetOnPCM(callback)` para receber PCM decodificado;
-- `SetOnRTP(callback)` para observação autenticada de baixo nível;
-- `SendOpus(...)` para o caminho codificado já existente.
-
-Essas funções ainda não são rotas HTTP. Expor áudio bruto sem autenticação de mídia, limite de fluxo e controle de sessão aumentaria a superfície de ataque.
+- acumula frames de 960 amostras/60 ms;
+- sanitiza `NaN`, infinito e amplitudes fora de `[-1, 1]`;
+- codifica MLow e envia por RTP/SRTP;
+- reordena e aplica PLC antes do decode recebido;
+- entrega PCM por callback interno;
+- serializa encoder/decoder por chamada;
+- espera envio e playout antes do teardown.
 
-## Build experimental
+## Ponte WebRTC do navegador
 
-A build padrão continua usando um transportador sem rede:
+A ponte do navegador usa WebRTC para fornecer DTLS/SCTP, mas transmite PCM em um DataChannel em vez de uma media track. Isso evita uma segunda pilha Opus no servidor e reutiliza diretamente o pipeline MLow existente.
 
-```bash
-go build ./cmd/evolution-go
+Ela só está disponível na build `voip_pion`. A build padrão responde `501 Not Implemented`.
+
+### Criar sessão
+
+A chamada deve estar em `active`.
+
+```http
+POST /call/{callId}/webrtc
+Content-Type: application/json
+apikey: INSTANCE_TOKEN
+```
+
+```json
+{
+  "offer": {
+    "type": "offer",
+    "sdp": "v=0\r\n..."
+  }
+}
+```
+
+O navegador deve criar previamente um DataChannel com:
+
+```text
+label: evolution-call-pcm
+protocol: evcall.pcm.v1
+ordered: true
 ```
 
-Para compilar a variante com relay Pion:
+Resposta `201`:
+
+```json
+{
+  "sessionId": "UUID",
+  "answer": {
+    "type": "answer",
+    "sdp": "v=0\r\n..."
+  },
+  "audio": {
+    "dataChannel": "evolution-call-pcm",
+    "protocol": "evcall.pcm.v1",
+    "format": "f32le",
+    "sampleRate": 16000,
+    "channels": 1,
+    "frameSamples": 960
+  }
+}
+```
+
+A API espera uma oferta completa com os candidatos ICE já coletados. Não há endpoint de trickle ICE nesta etapa.
+
+### Listar e fechar sessões
+
+```http
+GET /call/{callId}/webrtc
+apikey: INSTANCE_TOKEN
+```
+
+A resposta contém estado, frames de entrada/saída e descartes por sessão.
+
+```http
+DELETE /call/{callId}/webrtc/{sessionId}
+apikey: INSTANCE_TOKEN
+```
+
+Há limite de quatro sessões por chamada. Todas são fechadas automaticamente quando a chamada termina, é rejeitada, a instância desconecta ou o cliente WhatsApp é substituído.
+
+### Framing PCM `EVPC` versão 1
+
+Cada mensagem binária possui:
+
+| Offset | Tamanho | Campo |
+|---:|---:|---|
+| 0 | 4 | magic ASCII `EVPC` |
+| 4 | 1 | versão `1` |
+| 5 | 1 | tipo `1` para PCM |
+| 6 | 2 | flags, atualmente zero, little-endian |
+| 8 | 4 | sample rate `16000`, little-endian |
+| 12 | 4 | número de amostras, little-endian |
+| 16 | variável | amostras `float32` little-endian |
+
+O servidor aceita no máximo 3840 amostras por mensagem. O frame nominal contém 960 amostras. Filas internas possuem oito frames e o envio é descartado quando o buffer SCTP ultrapassa 512 KiB.
+
+### Exemplo pronto
+
+Abra o arquivo:
+
+```text
+docs/examples/call-webrtc-pcm.html
+```
+
+Ele implementa:
+
+- troca SDP autenticada;
+- `getUserMedia` com cancelamento de eco, redução de ruído e ganho automático;
+- resampling da taxa do `AudioContext` para 16 kHz;
+- envio em frames de 960 amostras;
+- resampling de 16 kHz para a taxa do dispositivo;
+- reprodução por `AudioWorklet`;
+- mute, backpressure e encerramento da sessão.
+
+O navegador exige HTTPS ou `localhost` para liberar o microfone.
+
+### Limitações de rede da ponte
+
+A configuração atual não injeta servidores STUN/TURN no PeerConnection do navegador. Portanto, ela funciona diretamente quando navegador e Evolution conseguem trocar candidatos host, mas pode falhar através de NATs ou redes remotas. TURN configurável é uma etapa posterior.
+
+## Build experimental
 
 ```bash
+go build ./cmd/evolution-go
 go build -tags=voip_pion ./cmd/evolution-go
 ```
 
-O workflow do PR testa permanentemente as duas variantes:
+O workflow testa permanentemente as duas variantes:
 
 ```bash
 go test -race ./pkg/call/...
 go test -race -tags=voip_pion ./pkg/call/...
 ```
 
+## Segurança e limites
+
+- todas as rotas SDP são autenticadas pela instância;
+- a chamada deve estar `active` antes de criar a sessão;
+- ofertas SDP acima de 256 KiB são rejeitadas;
+- mensagens de texto, labels/protocolos incorretos e frames PCM inválidos são descartados;
+- filas e buffer SCTP são limitados;
+- payloads e PCM temporários são sobrescritos antes do descarte;
+- chaves de chamada e SRTP nunca são entregues ao navegador;
+- nenhuma rota HTTP recebe áudio bruto.
+
 ## Limitações atuais
 
-- sem captura real de microfone;
-- sem reprodução em alto-falante;
-- sem ponte WebRTC para navegador;
-- sem resampling automático para fontes que não sejam mono/16 kHz;
-- jitter buffer ainda estático, sem ajuste adaptativo por atraso e variação da rede;
-- sem endpoint ou protocolo público de streaming de áudio;
-- a conexão real com um relay WhatsApp ainda precisa ser validada de ponta a ponta com uma conta conectada;
-- as chaves e sessões ficam somente em memória e não sobrevivem a reinícios;
-- API e formatos podem mudar enquanto o PR estiver em rascunho.
+- falta validar uma chamada real ponta a ponta com relay WhatsApp;
+- o exemplo realiza resampling linear, ainda sem filtro de alta qualidade;
+- não há STUN/TURN configurável para a ponte do navegador;
+- jitter do WhatsApp ainda é estático, sem ajuste adaptativo pela rede;
+- sessões e chaves ficam apenas em memória;
+- publicação normalizada de estados nos produtores de eventos continua pendente;
+- API e framing permanecem experimentais enquanto o PR estiver em rascunho.

From 788086b3093af733d2c1ab9ba56439934aa82aaa Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 19:24:57 -0300
Subject: [PATCH 146/266] ci(call): build complete server with and without Pion

---
 .github/workflows/voip-integration.yml | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/voip-integration.yml b/.github/workflows/voip-integration.yml
index 1b9754bc..ecbe7978 100644
--- a/.github/workflows/voip-integration.yml
+++ b/.github/workflows/voip-integration.yml
@@ -6,6 +6,7 @@ on:
       - dev/astracalls-integration
   pull_request:
     paths:
+      - "cmd/evolution-go/main.go"
       - "pkg/call/**"
       - "pkg/routes/routes.go"
       - "go.mod"
@@ -18,7 +19,7 @@ permissions:
 jobs:
   test-call-module:
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 20
 
     steps:
       - name: Checkout
@@ -38,3 +39,9 @@ jobs:
 
       - name: Test experimental Pion relay build
         run: go test -race -tags=voip_pion ./pkg/call/...
+
+      - name: Build default server
+        run: go build -o /tmp/evolution-go-default ./cmd/evolution-go
+
+      - name: Build experimental Pion server
+        run: go build -tags=voip_pion -o /tmp/evolution-go-pion ./cmd/evolution-go

From 92fafe88e177abd309fa7948be443223db500e1a Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 19:27:59 -0300
Subject: [PATCH 147/266] fix(routes): restore unlabel handler

---
 pkg/routes/routes.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 8d0f356b..52d297d6 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -226,7 +226,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) {
 	{
 		routes.Use(r.authMiddleware.Auth)
 		{
-			routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnlabel)
+			routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.labelHandler.ChatUnlabel)
 			routes.POST("/message", r.labelHandler.MessageUnlabel)
 		}
 	}

From 709ac79b75d38dad096869f01b1e400c19c47adf Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:15:02 -0300
Subject: [PATCH 148/266] feat(call): add fixed-port browser ICE networking

---
 pkg/call/voip/browser/network_pion.go | 167 ++++++++++++++++++++++++++
 1 file changed, 167 insertions(+)
 create mode 100644 pkg/call/voip/browser/network_pion.go

diff --git a/pkg/call/voip/browser/network_pion.go b/pkg/call/voip/browser/network_pion.go
new file mode 100644
index 00000000..1a733e0d
--- /dev/null
+++ b/pkg/call/voip/browser/network_pion.go
@@ -0,0 +1,167 @@
+//go:build voip_pion
+
+package browser
+
+import (
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+
+	"github.com/pion/webrtc/v4"
+)
+
+const (
+	browserPublicIPEnv = "CALL_WEBRTC_PUBLIC_IP"
+	browserMediaPortEnv = "CALL_WEBRTC_MEDIA_PORT"
+)
+
+type browserNetworkConfig struct {
+	enabled   bool
+	publicIP string
+	mediaPort int
+}
+
+type publicIPDetector func() string
+
+type environmentReader func(string) string
+
+var browserAPISingleton struct {
+	once sync.Once
+	api  *webrtc.API
+	err  error
+}
+
+// newBrowserPeerConnection uses one process-wide Pion API so every browser
+// session can share the configured UDP/TCP ICE muxes and fixed media port.
+func newBrowserPeerConnection() (*webrtc.PeerConnection, error) {
+	api, err := configuredBrowserAPI()
+	if err != nil {
+		return nil, err
+	}
+	return api.NewPeerConnection(webrtc.Configuration{})
+}
+
+func configuredBrowserAPI() (*webrtc.API, error) {
+	browserAPISingleton.once.Do(func() {
+		config, err := readBrowserNetworkConfig(os.Getenv, detectPublicIPv4)
+		if err != nil {
+			browserAPISingleton.err = err
+			return
+		}
+		api, _, actualPort, err := buildBrowserAPI(config)
+		if err != nil {
+			browserAPISingleton.err = err
+			return
+		}
+		browserAPISingleton.api = api
+		if config.enabled {
+			slog.Info("browser WebRTC fixed ICE endpoint enabled",
+				"public_ip", config.publicIP,
+				"media_port", actualPort,
+				"udp", true,
+				"ice_tcp", true,
+			)
+		}
+	})
+	if browserAPISingleton.err != nil {
+		return nil, browserAPISingleton.err
+	}
+	if browserAPISingleton.api == nil {
+		return nil, fmt.Errorf("browser WebRTC API is not initialized")
+	}
+	return browserAPISingleton.api, nil
+}
+
+func readBrowserNetworkConfig(getenv environmentReader, detect publicIPDetector) (browserNetworkConfig, error) {
+	if getenv == nil {
+		getenv = os.Getenv
+	}
+	publicIP := strings.TrimSpace(getenv(browserPublicIPEnv))
+	portValue := strings.TrimSpace(getenv(browserMediaPortEnv))
+	if publicIP == "" && portValue == "" {
+		return browserNetworkConfig{}, nil
+	}
+	if publicIP == "" || portValue == "" {
+		return browserNetworkConfig{}, fmt.Errorf("%s and %s must be configured together", browserPublicIPEnv, browserMediaPortEnv)
+	}
+	if strings.EqualFold(publicIP, "auto") {
+		if detect == nil {
+			return browserNetworkConfig{}, fmt.Errorf("detect public IPv4 address: detector is unavailable")
+		}
+		publicIP = strings.TrimSpace(detect())
+		if publicIP == "" {
+			return browserNetworkConfig{}, fmt.Errorf("detect public IPv4 address for %s=auto", browserPublicIPEnv)
+		}
+	}
+	parsedIP := net.ParseIP(publicIP)
+	if parsedIP == nil || parsedIP.To4() == nil {
+		return browserNetworkConfig{}, fmt.Errorf("%s must be an IPv4 address or auto", browserPublicIPEnv)
+	}
+	mediaPort, err := strconv.Atoi(portValue)
+	if err != nil || mediaPort < 1 || mediaPort > 65535 {
+		return browserNetworkConfig{}, fmt.Errorf("%s must be an integer between 1 and 65535", browserMediaPortEnv)
+	}
+	return browserNetworkConfig{
+		enabled:   true,
+		publicIP: parsedIP.To4().String(),
+		mediaPort: mediaPort,
+	}, nil
+}
+
+// buildBrowserAPI binds UDP and TCP on the same port. A zero mediaPort is
+// accepted only for tests; environment parsing always requires a fixed port.
+func buildBrowserAPI(config browserNetworkConfig) (*webrtc.API, []io.Closer, int, error) {
+	if !config.enabled {
+		return webrtc.NewAPI(), nil, 0, nil
+	}
+	if parsedIP := net.ParseIP(config.publicIP); parsedIP == nil || parsedIP.To4() == nil {
+		return nil, nil, 0, fmt.Errorf("invalid browser WebRTC advertised IPv4 address %q", config.publicIP)
+	}
+	if config.mediaPort < 0 || config.mediaPort > 65535 {
+		return nil, nil, 0, fmt.Errorf("invalid browser WebRTC media port %d", config.mediaPort)
+	}
+
+	udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: config.mediaPort})
+	if err != nil {
+		return nil, nil, 0, fmt.Errorf("bind browser WebRTC UDP port %d: %w", config.mediaPort, err)
+	}
+	actualPort := udpConn.LocalAddr().(*net.UDPAddr).Port
+	tcpListener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4zero, Port: actualPort})
+	if err != nil {
+		_ = udpConn.Close()
+		return nil, nil, 0, fmt.Errorf("bind browser WebRTC ICE-TCP port %d: %w", actualPort, err)
+	}
+
+	settingEngine := webrtc.SettingEngine{}
+	settingEngine.SetNAT1To1IPs([]string{config.publicIP}, webrtc.ICECandidateTypeHost)
+	settingEngine.SetNetworkTypes([]webrtc.NetworkType{
+		webrtc.NetworkTypeUDP4,
+		webrtc.NetworkTypeTCP4,
+	})
+	settingEngine.SetICEUDPMux(webrtc.NewICEUDPMux(nil, udpConn))
+	settingEngine.SetICETCPMux(webrtc.NewICETCPMux(nil, tcpListener, 8))
+
+	api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
+	return api, []io.Closer{tcpListener, udpConn}, actualPort, nil
+}
+
+// detectPublicIPv4 resolves the local IPv4 selected by the default route. On a
+// host-networked VPS this is normally the public address. Behind NAT, set the
+// externally routed address explicitly instead of using auto.
+func detectPublicIPv4() string {
+	connection, err := net.Dial("udp4", "8.8.8.8:80")
+	if err != nil {
+		return ""
+	}
+	defer connection.Close()
+	address, ok := connection.LocalAddr().(*net.UDPAddr)
+	if !ok || address.IP == nil {
+		return ""
+	}
+	return address.IP.To4().String()
+}

From b738976c8163cdd3abab997420d47db3d4d26625 Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:15:44 -0300
Subject: [PATCH 149/266] test(call): cover fixed-port browser ICE networking

---
 pkg/call/voip/browser/network_pion_test.go | 166 +++++++++++++++++++++
 1 file changed, 166 insertions(+)
 create mode 100644 pkg/call/voip/browser/network_pion_test.go

diff --git a/pkg/call/voip/browser/network_pion_test.go b/pkg/call/voip/browser/network_pion_test.go
new file mode 100644
index 00000000..c4d24074
--- /dev/null
+++ b/pkg/call/voip/browser/network_pion_test.go
@@ -0,0 +1,166 @@
+//go:build voip_pion
+
+package browser
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/pion/webrtc/v4"
+)
+
+func TestReadBrowserNetworkConfig(t *testing.T) {
+	tests := []struct {
+		name       string
+		environment map[string]string
+		detected   string
+		want       browserNetworkConfig
+		wantError  bool
+	}{
+		{name: "disabled", environment: map[string]string{}},
+		{
+			name: "explicit endpoint",
+			environment: map[string]string{
+				browserPublicIPEnv: "203.0.113.20",
+				browserMediaPortEnv: "50000",
+			},
+			want: browserNetworkConfig{enabled: true, publicIP: "203.0.113.20", mediaPort: 50000},
+		},
+		{
+			name: "automatic address",
+			environment: map[string]string{
+				browserPublicIPEnv: "auto",
+				browserMediaPortEnv: "40000",
+			},
+			detected: "198.51.100.8",
+			want: browserNetworkConfig{enabled: true, publicIP: "198.51.100.8", mediaPort: 40000},
+		},
+		{
+			name: "missing port",
+			environment: map[string]string{browserPublicIPEnv: "203.0.113.20"},
+			wantError: true,
+		},
+		{
+			name: "invalid address",
+			environment: map[string]string{
+				browserPublicIPEnv: "not-an-ip",
+				browserMediaPortEnv: "50000",
+			},
+			wantError: true,
+		},
+		{
+			name: "invalid port",
+			environment: map[string]string{
+				browserPublicIPEnv: "203.0.113.20",
+				browserMediaPortEnv: "70000",
+			},
+			wantError: true,
+		},
+		{
+			name: "automatic detection failed",
+			environment: map[string]string{
+				browserPublicIPEnv: "auto",
+				browserMediaPortEnv: "50000",
+			},
+			wantError: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			getenv := func(key string) string { return test.environment[key] }
+			config, err := readBrowserNetworkConfig(getenv, func() string { return test.detected })
+			if test.wantError {
+				if err == nil {
+					t.Fatalf("expected configuration error, got %+v", config)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+			if config != test.want {
+				t.Fatalf("unexpected configuration: got %+v want %+v", config, test.want)
+			}
+		})
+	}
+}
+
+func TestBrowserAPIAdvertisesFixedUDPAndTCPPort(t *testing.T) {
+	api, closers, mediaPort, err := buildBrowserAPI(browserNetworkConfig{
+		enabled: true,
+		publicIP: "127.0.0.1",
+		mediaPort: 0,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		for _, closer := range closers {
+			_ = closer.Close()
+		}
+	}()
+
+	server, err := api.NewPeerConnection(webrtc.Configuration{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+	client, err := webrtc.NewPeerConnection(webrtc.Configuration{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer client.Close()
+	if _, err = client.CreateDataChannel(DataChannelLabel, nil); err != nil {
+		t.Fatal(err)
+	}
+
+	offer, err := client.CreateOffer(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	clientGathering := webrtc.GatheringCompletePromise(client)
+	if err = client.SetLocalDescription(offer); err != nil {
+		t.Fatal(err)
+	}
+	select {
+	case <-clientGathering:
+	case <-time.After(10 * time.Second):
+		t.Fatal("client ICE gathering timed out")
+	}
+
+	if err = server.SetRemoteDescription(*client.LocalDescription()); err != nil {
+		t.Fatal(err)
+	}
+	answer, err := server.CreateAnswer(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	serverGathering := webrtc.GatheringCompletePromise(server)
+	if err = server.SetLocalDescription(answer); err != nil {
+		t.Fatal(err)
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	select {
+	case <-serverGathering:
+	case <-ctx.Done():
+		t.Fatal("server ICE gathering timed out")
+	}
+
+	sdp := server.LocalDescription().SDP
+	endpoint := fmt.Sprintf(" 127.0.0.1 %d typ host", mediaPort)
+	if !strings.Contains(sdp, endpoint) {
+		t.Fatalf("SDP does not advertise fixed endpoint %q:\n%s", endpoint, sdp)
+	}
+	lowerSDP := strings.ToLower(sdp)
+	if !strings.Contains(lowerSDP, " udp ") {
+		t.Fatalf("SDP does not contain a UDP candidate:\n%s", sdp)
+	}
+	if !strings.Contains(lowerSDP, " tcp ") || !strings.Contains(lowerSDP, "tcptype passive") {
+		t.Fatalf("SDP does not contain a passive ICE-TCP candidate:\n%s", sdp)
+	}
+}

From f3522393d0f2f3ca7aa0a75bcb09ac7a3e5e623c Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:16:34 -0300
Subject: [PATCH 150/266] feat(call): use shared fixed-port browser Pion API

---
 pkg/call/voip/browser/manager_pion.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pkg/call/voip/browser/manager_pion.go b/pkg/call/voip/browser/manager_pion.go
index 30d90d0a..0cd52fb7 100644
--- a/pkg/call/voip/browser/manager_pion.go
+++ b/pkg/call/voip/browser/manager_pion.go
@@ -86,7 +86,7 @@ func (m *pionManager) Create(ctx context.Context, instanceID, callID string, req
 	sessionID := uuid.NewString()
 	m.mu.Unlock()
 
-	pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
+	pc, err := newBrowserPeerConnection()
 	if err != nil {
 		return CreateResponse{}, fmt.Errorf("create browser peer connection: %w", err)
 	}

From 10bb6a1d7dafe34a80d78ef7ef0d873ab7ec7bf4 Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:18:05 -0300
Subject: [PATCH 151/266] docs(call): document public fixed-port browser ICE

---
 docs/wiki/guias-api/api-calls-experimental.md | 64 +++++++++++++++++--
 1 file changed, 59 insertions(+), 5 deletions(-)

diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md
index 3fb79723..f5d3e65c 100644
--- a/docs/wiki/guias-api/api-calls-experimental.md
+++ b/docs/wiki/guias-api/api-calls-experimental.md
@@ -262,9 +262,53 @@ Ele implementa:
 
 O navegador exige HTTPS ou `localhost` para liberar o microfone.
 
-### Limitações de rede da ponte
+### Redes diferentes sem TURN
 
-A configuração atual não injeta servidores STUN/TURN no PeerConnection do navegador. Portanto, ela funciona diretamente quando navegador e Evolution conseguem trocar candidatos host, mas pode falhar através de NATs ou redes remotas. TURN configurável é uma etapa posterior.
+Sem configuração adicional, o Pion mantém candidatos host e portas efêmeras, adequado para desenvolvimento local. Para publicar a ponte diretamente na internet, configure as duas variáveis antes de iniciar o processo:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+Também é aceito:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=auto
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+`auto` consulta o endereço IPv4 escolhido pela rota padrão. Ele é apropriado quando o Evolution roda diretamente em uma VPS com o IP público na interface. Em máquinas atrás de NAT, informe explicitamente o endereço externo encaminhado.
+
+Quando as variáveis estão presentes, o runtime:
+
+- anuncia `CALL_WEBRTC_PUBLIC_IP` como candidato ICE host por NAT 1:1;
+- abre `0.0.0.0:CALL_WEBRTC_MEDIA_PORT` em UDP;
+- abre a mesma porta em TCP para fallback ICE-TCP passivo;
+- usa um `ICEUDPMux` e um `ICETCPMux` compartilhados por todas as sessões;
+- mantém a troca SDP completa, sem trickle ICE;
+- não depende de um servidor STUN/TURN externo.
+
+As duas variáveis são obrigatórias em conjunto. Endereço, porta ou bind inválidos fazem a criação da sessão WebRTC falhar explicitamente; o sistema não troca silenciosamente para portas efêmeras.
+
+No firewall ou security group, libere a mesma porta nos dois protocolos:
+
+```text
+UDP 50000 entrada
+TCP 50000 entrada
+```
+
+Em Docker, use rede host ou encaminhe a porta fixa diretamente:
+
+```yaml
+ports:
+  - "50000:50000/udp"
+  - "50000:50000/tcp"
+```
+
+Traefik, Nginx e outros proxies HTTP continuam responsáveis apenas por HTTPS/API. A mídia ICE chega diretamente à porta UDP/TCP configurada.
+
+TURN ainda pode ser necessário em redes corporativas que bloqueiem tanto UDP quanto ICE-TCP para portas externas, ou quando o servidor não possui qualquer porta publicamente encaminhável.
 
 ## Build experimental
 
@@ -273,6 +317,14 @@ go build ./cmd/evolution-go
 go build -tags=voip_pion ./cmd/evolution-go
 ```
 
+Exemplo de execução pública:
+
+```bash
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+CALL_WEBRTC_MEDIA_PORT=50000 \
+./evolution-go
+```
+
 O workflow testa permanentemente as duas variantes:
 
 ```bash
@@ -289,14 +341,16 @@ go test -race -tags=voip_pion ./pkg/call/...
 - filas e buffer SCTP são limitados;
 - payloads e PCM temporários são sobrescritos antes do descarte;
 - chaves de chamada e SRTP nunca são entregues ao navegador;
-- nenhuma rota HTTP recebe áudio bruto.
+- nenhuma rota HTTP recebe áudio bruto;
+- a porta de mídia aceita tráfego ICE público e deve ser protegida por firewall contra origens e volumes abusivos.
 
 ## Limitações atuais
 
 - falta validar uma chamada real ponta a ponta com relay WhatsApp;
 - o exemplo realiza resampling linear, ainda sem filtro de alta qualidade;
-- não há STUN/TURN configurável para a ponte do navegador;
+- a publicação de mídia fixa atualmente aceita somente IPv4;
+- não há TURN integrado para redes que bloqueiem UDP e ICE-TCP;
 - jitter do WhatsApp ainda é estático, sem ajuste adaptativo pela rede;
 - sessões e chaves ficam apenas em memória;
 - publicação normalizada de estados nos produtores de eventos continua pendente;
-- API e framing permanecem experimentais enquanto o PR estiver em rascunho.
+- API e framing permanecem experimentais enquanto o PR estiver em rascunho.
\ No newline at end of file

From 61d8d7ae06e1142046d301a5857ab3ef76e2ac59 Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:18:50 -0300
Subject: [PATCH 152/266] build(call): allow Pion-tagged Docker images

---
 Dockerfile | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index 462ed49d..f2f2a759 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,7 +15,10 @@ RUN go mod download
 COPY . .
 
 ARG VERSION=dev
-RUN CGO_ENABLED=1 go build -ldflags "-X main.version=${VERSION}" -o server ./cmd/evolution-go
+# Mantém a imagem padrão sem tags. Para habilitar chamadas com Pion, use:
+# docker build --build-arg GO_BUILD_TAGS=voip_pion ...
+ARG GO_BUILD_TAGS=""
+RUN CGO_ENABLED=1 go build -tags "${GO_BUILD_TAGS}" -ldflags "-X main.version=${VERSION}" -o server ./cmd/evolution-go
 
 FROM alpine:3.19.1 AS final
 
@@ -30,4 +33,4 @@ COPY --from=build /build/VERSION ./VERSION
 
 ENV TZ=America/Sao_Paulo
 
-ENTRYPOINT ["/app/server"]
+ENTRYPOINT ["/app/server"]
\ No newline at end of file

From df65708fc96c6e1ea61d5d6ef70f2db7481a197d Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:19:24 -0300
Subject: [PATCH 153/266] docs(call): add public WebRTC deployment guide

---
 docs/examples/call-webrtc-public-deploy.md | 140 +++++++++++++++++++++
 1 file changed, 140 insertions(+)
 create mode 100644 docs/examples/call-webrtc-public-deploy.md

diff --git a/docs/examples/call-webrtc-public-deploy.md b/docs/examples/call-webrtc-public-deploy.md
new file mode 100644
index 00000000..9b18b537
--- /dev/null
+++ b/docs/examples/call-webrtc-public-deploy.md
@@ -0,0 +1,140 @@
+# Implantação pública da ponte WebRTC de chamadas
+
+Este guia publica a ponte navegador ⇄ Evolution em uma única porta UDP/TCP, sem depender de STUN ou TURN externo.
+
+> A mídia do navegador só existe na build `voip_pion`. A chamada WhatsApp também precisa chegar ao estado `active` antes da criação da sessão WebRTC.
+
+## Variáveis obrigatórias
+
+Configure o IPv4 anunciado e a porta de mídia:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+Em uma VPS cujo IPv4 público está diretamente associado à interface, também é possível usar:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=auto
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+`auto` detecta o IPv4 escolhido pela rota padrão. Em servidores atrás de NAT, balanceador ou encaminhamento de porta, use explicitamente o endereço externo.
+
+As duas variáveis devem ser definidas juntas. Configuração parcial, endereço inválido, porta inválida ou falha de bind impedem a criação da sessão WebRTC.
+
+## Compilação direta
+
+```bash
+go build -tags=voip_pion -o evolution-go ./cmd/evolution-go
+
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+CALL_WEBRTC_MEDIA_PORT=50000 \
+./evolution-go
+```
+
+## Imagem Docker
+
+O Dockerfile mantém a build padrão quando nenhum argumento é informado. Para incluir o Pion:
+
+```bash
+docker build \
+  --build-arg GO_BUILD_TAGS=voip_pion \
+  -t evolution-go:voip-pion .
+```
+
+### Rede host
+
+É a opção mais simples em uma VPS Linux:
+
+```bash
+docker run --rm \
+  --network host \
+  -e CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+  -e CALL_WEBRTC_MEDIA_PORT=50000 \
+  evolution-go:voip-pion
+```
+
+### Encaminhamento explícito
+
+Quando a rede host não estiver disponível, publique a mesma porta nos dois protocolos:
+
+```bash
+docker run --rm \
+  -p 8080:8080/tcp \
+  -p 50000:50000/udp \
+  -p 50000:50000/tcp \
+  -e CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+  -e CALL_WEBRTC_MEDIA_PORT=50000 \
+  evolution-go:voip-pion
+```
+
+Exemplo equivalente em Compose:
+
+```yaml
+services:
+  evolution:
+    build:
+      context: .
+      args:
+        GO_BUILD_TAGS: voip_pion
+    environment:
+      CALL_WEBRTC_PUBLIC_IP: 203.0.113.10
+      CALL_WEBRTC_MEDIA_PORT: "50000"
+    ports:
+      - "8080:8080/tcp"
+      - "50000:50000/udp"
+      - "50000:50000/tcp"
+```
+
+## Firewall e proxy
+
+Libere no firewall ou security group:
+
+```text
+UDP 50000 entrada
+TCP 50000 entrada
+```
+
+Traefik, Nginx ou Caddy podem publicar a API e a página em HTTPS, mas não devem intermediar a porta ICE. O tráfego WebRTC chega diretamente ao Evolution:
+
+```text
+navegador ── UDP 50000 ──► Evolution
+          └─ TCP 50000 ──► Evolution, quando UDP falha
+```
+
+O HTTPS continua obrigatório para que navegadores remotos liberem `getUserMedia`.
+
+## Comportamento do runtime
+
+Quando as variáveis estão configuradas, o processo cria uma única API Pion compartilhada e:
+
+- anuncia o IPv4 configurado por NAT 1:1;
+- usa `ICEUDPMux` na porta fixa;
+- usa `ICETCPMux` passivo na mesma porta;
+- compartilha os muxes entre todas as sessões e chamadas;
+- mantém os limites de quatro sessões por chamada e oito frames por fila;
+- não entrega chaves WhatsApp ou SRTP ao navegador.
+
+Sem as variáveis, a ponte continua usando candidatos host e portas efêmeras para desenvolvimento local.
+
+## Quando TURN ainda é necessário
+
+Esta estratégia cobre VPSs e servidores com uma porta pública encaminhável. TURN ainda pode ser necessário quando:
+
+- o servidor está atrás de CGNAT sem encaminhamento;
+- a rede do navegador bloqueia UDP e também ICE-TCP nessa porta;
+- somente tráfego por 443 através de um relay é permitido;
+- a implantação exige compatibilidade máxima em redes corporativas restritas.
+
+## Checklist de validação
+
+1. Confirme que a imagem foi compilada com `GO_BUILD_TAGS=voip_pion`.
+2. Confirme que UDP e TCP estão liberados na porta configurada.
+3. Inicie o Evolution com as duas variáveis.
+4. Verifique o log `browser WebRTC fixed ICE endpoint enabled`.
+5. Deixe a chamada WhatsApp chegar a `active`.
+6. Abra `docs/examples/call-webrtc-pcm.html` por HTTPS.
+7. Crie a sessão e confirme candidatos com o IPv4 e a porta pública no SDP answer.
+8. Teste microfone e reprodução a partir de outra rede.

From a5915cca2df1f42570e556f08288eff63cf43ad6 Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:42:04 -0300
Subject: [PATCH 154/266] feat(manager): add call panel styles

---
 manager/dist/assets/call-manager.css | 446 +++++++++++++++++++++++++++
 1 file changed, 446 insertions(+)
 create mode 100644 manager/dist/assets/call-manager.css

diff --git a/manager/dist/assets/call-manager.css b/manager/dist/assets/call-manager.css
new file mode 100644
index 00000000..71e699a5
--- /dev/null
+++ b/manager/dist/assets/call-manager.css
@@ -0,0 +1,446 @@
+#evcall-root,
+#evcall-root * {
+  box-sizing: border-box;
+}
+
+#evcall-root {
+  --evcall-bg: #ffffff;
+  --evcall-surface: #f7f8fa;
+  --evcall-border: #dfe3e8;
+  --evcall-text: #17212b;
+  --evcall-muted: #68727d;
+  --evcall-primary: #168a57;
+  --evcall-primary-hover: #117247;
+  --evcall-danger: #d14343;
+  --evcall-danger-hover: #b93636;
+  --evcall-shadow: 0 20px 55px rgba(17, 24, 39, 0.24);
+  color: var(--evcall-text);
+  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  position: fixed;
+  right: 20px;
+  bottom: 20px;
+  z-index: 2147483000;
+}
+
+@media (prefers-color-scheme: dark) {
+  #evcall-root {
+    --evcall-bg: #15191e;
+    --evcall-surface: #20262d;
+    --evcall-border: #343d47;
+    --evcall-text: #f5f7f9;
+    --evcall-muted: #a9b2bc;
+    --evcall-shadow: 0 20px 55px rgba(0, 0, 0, 0.55);
+  }
+}
+
+.evcall-launcher {
+  align-items: center;
+  background: var(--evcall-primary);
+  border: 0;
+  border-radius: 50%;
+  bottom: 0;
+  box-shadow: 0 10px 28px rgba(22, 138, 87, 0.38);
+  color: #fff;
+  cursor: pointer;
+  display: flex;
+  font-size: 25px;
+  height: 58px;
+  justify-content: center;
+  position: absolute;
+  right: 0;
+  transition: transform 0.16s ease, background 0.16s ease;
+  width: 58px;
+}
+
+.evcall-launcher:hover,
+.evcall-launcher.active {
+  background: var(--evcall-primary-hover);
+  transform: translateY(-2px);
+}
+
+.evcall-launcher:focus-visible,
+.evcall-panel button:focus-visible,
+.evcall-panel input:focus-visible,
+.evcall-panel summary:focus-visible {
+  outline: 3px solid rgba(45, 145, 255, 0.5);
+  outline-offset: 2px;
+}
+
+.evcall-badge {
+  align-items: center;
+  background: var(--evcall-danger);
+  border: 2px solid #fff;
+  border-radius: 999px;
+  color: #fff;
+  display: flex;
+  font-size: 11px;
+  font-weight: 700;
+  height: 22px;
+  justify-content: center;
+  min-width: 22px;
+  padding: 0 5px;
+  position: absolute;
+  right: -4px;
+  top: -5px;
+}
+
+.evcall-panel {
+  background: var(--evcall-bg);
+  border: 1px solid var(--evcall-border);
+  border-radius: 16px;
+  bottom: 72px;
+  box-shadow: var(--evcall-shadow);
+  max-height: min(760px, calc(100vh - 110px));
+  overflow: hidden;
+  position: absolute;
+  right: 0;
+  width: min(420px, calc(100vw - 32px));
+}
+
+.evcall-panel[hidden],
+.evcall-current[hidden],
+.evcall-panel button[hidden],
+.evcall-badge[hidden] {
+  display: none !important;
+}
+
+.evcall-header {
+  align-items: center;
+  background: var(--evcall-bg);
+  border-bottom: 1px solid var(--evcall-border);
+  display: flex;
+  justify-content: space-between;
+  padding: 15px 17px;
+}
+
+.evcall-header > div {
+  display: grid;
+  gap: 3px;
+}
+
+.evcall-header strong {
+  font-size: 15px;
+}
+
+.evcall-header small,
+.evcall-empty,
+.evcall-media-state,
+.evcall-list-item small {
+  color: var(--evcall-muted);
+  font-size: 12px;
+}
+
+.evcall-body {
+  display: grid;
+  gap: 14px;
+  max-height: calc(min(760px, 100vh - 110px) - 62px);
+  overflow-y: auto;
+  padding: 14px;
+}
+
+.evcall-icon {
+  align-items: center;
+  background: transparent;
+  border: 0;
+  border-radius: 8px;
+  color: var(--evcall-muted);
+  cursor: pointer;
+  display: inline-flex;
+  font-size: 21px;
+  height: 34px;
+  justify-content: center;
+  width: 34px;
+}
+
+.evcall-icon:hover {
+  background: var(--evcall-surface);
+  color: var(--evcall-text);
+}
+
+.evcall-settings {
+  background: var(--evcall-surface);
+  border: 1px solid var(--evcall-border);
+  border-radius: 12px;
+  padding: 10px 12px;
+}
+
+.evcall-settings summary,
+.evcall-log-wrap summary {
+  cursor: pointer;
+  font-size: 13px;
+  font-weight: 700;
+  user-select: none;
+}
+
+.evcall-grid {
+  display: grid;
+  gap: 10px;
+  padding-top: 11px;
+}
+
+.evcall-panel label {
+  color: var(--evcall-muted);
+  display: grid;
+  font-size: 12px;
+  gap: 5px;
+}
+
+.evcall-panel input {
+  background: var(--evcall-bg);
+  border: 1px solid var(--evcall-border);
+  border-radius: 9px;
+  color: var(--evcall-text);
+  font: inherit;
+  min-height: 40px;
+  padding: 9px 11px;
+  width: 100%;
+}
+
+.evcall-panel input::placeholder {
+  color: var(--evcall-muted);
+  opacity: 0.7;
+}
+
+.evcall-check {
+  align-items: center;
+  display: flex !important;
+  gap: 8px !important;
+}
+
+.evcall-check input {
+  min-height: 0;
+  width: auto;
+}
+
+.evcall-dialer {
+  align-items: end;
+  display: grid;
+  gap: 9px;
+  grid-template-columns: 1fr auto;
+}
+
+.evcall-panel button:not(.evcall-icon):not(.evcall-launcher) {
+  border: 0;
+  border-radius: 9px;
+  cursor: pointer;
+  font: inherit;
+  font-size: 13px;
+  font-weight: 700;
+  min-height: 40px;
+  padding: 9px 13px;
+  transition: filter 0.14s ease, transform 0.08s ease;
+}
+
+.evcall-panel button:not(.evcall-icon):not(.evcall-launcher):active {
+  transform: scale(0.98);
+}
+
+.evcall-panel button:disabled {
+  cursor: wait;
+  opacity: 0.55;
+}
+
+.evcall-primary {
+  background: var(--evcall-primary);
+  color: #fff;
+}
+
+.evcall-primary:hover {
+  background: var(--evcall-primary-hover);
+}
+
+.evcall-danger {
+  background: var(--evcall-danger);
+  color: #fff;
+}
+
+.evcall-danger:hover {
+  background: var(--evcall-danger-hover);
+}
+
+.evcall-secondary {
+  background: var(--evcall-surface);
+  color: var(--evcall-text);
+  outline: 1px solid var(--evcall-border);
+}
+
+.evcall-secondary:hover {
+  filter: brightness(0.97);
+}
+
+.evcall-current {
+  background: var(--evcall-surface);
+  border: 1px solid var(--evcall-border);
+  border-radius: 13px;
+  display: grid;
+  gap: 11px;
+  padding: 13px;
+}
+
+.evcall-current-main {
+  display: grid;
+  gap: 3px;
+}
+
+.evcall-direction {
+  color: var(--evcall-muted);
+  font-size: 11px;
+  font-weight: 700;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+}
+
+.evcall-peer {
+  font-size: 18px;
+  overflow-wrap: anywhere;
+}
+
+.evcall-state,
+.evcall-pill {
+  border-radius: 999px;
+  display: inline-flex;
+  font-size: 11px;
+  font-weight: 700;
+  justify-self: start;
+  padding: 4px 8px;
+}
+
+.state-ringing {
+  background: #fff0c2;
+  color: #8a5a00;
+}
+
+.state-connecting {
+  background: #d9ecff;
+  color: #075a9c;
+}
+
+.state-active {
+  background: #d8f5e5;
+  color: #11653e;
+}
+
+.state-ended,
+.state-idle {
+  background: #e9edf1;
+  color: #5c6670;
+}
+
+.state-failed,
+.state-unknown {
+  background: #ffe0e0;
+  color: #9f2525;
+}
+
+.evcall-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.evcall-media-state {
+  line-height: 1.45;
+}
+
+.evcall-section-head {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+}
+
+.evcall-section-head strong {
+  font-size: 13px;
+}
+
+.evcall-list {
+  display: grid;
+  gap: 8px;
+}
+
+.evcall-empty {
+  border: 1px dashed var(--evcall-border);
+  border-radius: 10px;
+  margin: 0;
+  padding: 16px;
+  text-align: center;
+}
+
+.evcall-list-item {
+  background: var(--evcall-bg) !important;
+  border: 1px solid var(--evcall-border) !important;
+  color: var(--evcall-text) !important;
+  display: grid;
+  gap: 5px;
+  min-height: 0 !important;
+  padding: 10px 11px !important;
+  text-align: left;
+  width: 100%;
+}
+
+.evcall-list-item:hover,
+.evcall-list-item.selected {
+  border-color: var(--evcall-primary) !important;
+}
+
+.evcall-list-item.selected {
+  box-shadow: inset 3px 0 0 var(--evcall-primary);
+}
+
+.evcall-list-top {
+  align-items: center;
+  display: flex;
+  gap: 9px;
+  justify-content: space-between;
+}
+
+.evcall-list-top strong {
+  font-size: 13px;
+  overflow-wrap: anywhere;
+}
+
+.evcall-list-item small {
+  display: block;
+  font-weight: 400;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.evcall-log-wrap {
+  border-top: 1px solid var(--evcall-border);
+  padding-top: 11px;
+}
+
+.evcall-log {
+  background: #10151b;
+  border-radius: 9px;
+  color: #d8e4ef;
+  font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  margin: 9px 0 0;
+  max-height: 180px;
+  min-height: 80px;
+  overflow: auto;
+  padding: 10px;
+  white-space: pre-wrap;
+}
+
+@media (max-width: 520px) {
+  #evcall-root {
+    bottom: 12px;
+    right: 12px;
+  }
+
+  .evcall-panel {
+    bottom: 68px;
+    max-height: calc(100vh - 96px);
+    width: calc(100vw - 24px);
+  }
+
+  .evcall-body {
+    max-height: calc(100vh - 158px);
+  }
+
+  .evcall-dialer {
+    grid-template-columns: 1fr;
+  }
+}

From af1ca62c6ae50e90ca144464d0344d82e6639250 Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:43:07 -0300
Subject: [PATCH 155/266] feat(manager): add authenticated call panel

---
 manager/dist/assets/call-manager.js | 707 ++++++++++++++++++++++++++++
 1 file changed, 707 insertions(+)
 create mode 100644 manager/dist/assets/call-manager.js

diff --git a/manager/dist/assets/call-manager.js b/manager/dist/assets/call-manager.js
new file mode 100644
index 00000000..4bdb5090
--- /dev/null
+++ b/manager/dist/assets/call-manager.js
@@ -0,0 +1,707 @@
+(() => {
+  "use strict";
+
+  if (window.__evolutionCallManagerLoaded) return;
+  window.__evolutionCallManagerLoaded = true;
+
+  const DATA_CHANNEL_LABEL = "evolution-call-pcm";
+  const DATA_CHANNEL_PROTOCOL = "evcall.pcm.v1";
+  const PCM_RATE = 16000;
+  const PCM_FRAME_SAMPLES = 960;
+  const MAX_BUFFERED_AMOUNT = 256 * 1024;
+  const HEADER_BYTES = 16;
+  const STORAGE_KEY = "evolution.callManager.config.v1";
+  const SESSION_KEY = "evolution.callManager.session.v1";
+  const POLL_INTERVAL_MS = 1800;
+
+  const state = {
+    open: false,
+    loading: false,
+    calls: [],
+    selectedCallId: "",
+    autoConnectCallId: "",
+    pollTimer: null,
+    peer: null,
+    channel: null,
+    sessionId: "",
+    mediaCallId: "",
+    audioContext: null,
+    microphoneStream: null,
+    captureSource: null,
+    captureNode: null,
+    playbackNode: null,
+    captureResampler: null,
+    playbackResampler: null,
+    capturePending: new Float32Array(0),
+    muted: false,
+    sentFrames: 0,
+    receivedFrames: 0,
+    droppedFrames: 0,
+  };
+
+  const root = document.createElement("div");
+  root.id = "evcall-root";
+  root.innerHTML = `
+    
+    
+  `;
+  document.body.appendChild(root);
+
+  const ui = {
+    launcher: root.querySelector(".evcall-launcher"),
+    badge: root.querySelector(".evcall-badge"),
+    panel: root.querySelector(".evcall-panel"),
+    close: root.querySelector(".evcall-close"),
+    runtime: root.querySelector(".evcall-runtime"),
+    settings: root.querySelector(".evcall-settings"),
+    baseUrl: root.querySelector(".evcall-base-url"),
+    apiKey: root.querySelector(".evcall-api-key"),
+    remember: root.querySelector(".evcall-remember"),
+    save: root.querySelector(".evcall-save"),
+    number: root.querySelector(".evcall-number"),
+    start: root.querySelector(".evcall-start"),
+    current: root.querySelector(".evcall-current"),
+    direction: root.querySelector(".evcall-direction"),
+    peer: root.querySelector(".evcall-peer"),
+    callState: root.querySelector(".evcall-state"),
+    accept: root.querySelector(".evcall-accept"),
+    reject: root.querySelector(".evcall-reject"),
+    connect: root.querySelector(".evcall-connect"),
+    mute: root.querySelector(".evcall-mute"),
+    hangup: root.querySelector(".evcall-hangup"),
+    mediaState: root.querySelector(".evcall-media-state"),
+    refresh: root.querySelector(".evcall-refresh"),
+    list: root.querySelector(".evcall-list"),
+    log: root.querySelector(".evcall-log"),
+  };
+
+  class StreamingLinearResampler {
+    constructor(inputRate, outputRate) {
+      this.step = inputRate / outputRate;
+      this.position = 0;
+      this.carry = new Float32Array(0);
+    }
+
+    push(input) {
+      if (!(input instanceof Float32Array) || input.length === 0) return new Float32Array(0);
+      const data = new Float32Array(this.carry.length + input.length);
+      data.set(this.carry);
+      data.set(input, this.carry.length);
+      const output = [];
+      let position = this.position;
+      while (position + 1 < data.length) {
+        const left = Math.floor(position);
+        const fraction = position - left;
+        output.push(data[left] + (data[left + 1] - data[left]) * fraction);
+        position += this.step;
+      }
+      const consumed = Math.floor(position);
+      this.carry = data.slice(Math.min(consumed, data.length));
+      this.position = position - consumed;
+      return Float32Array.from(output);
+    }
+  }
+
+  function safeJSON(value, fallback = null) {
+    try { return JSON.parse(value); } catch (_) { return fallback; }
+  }
+
+  function loadConfig() {
+    const persistent = safeJSON(localStorage.getItem(STORAGE_KEY), null);
+    const temporary = safeJSON(sessionStorage.getItem(SESSION_KEY), null);
+    const config = persistent || temporary || {};
+    ui.baseUrl.value = config.baseUrl || window.location.origin;
+    ui.apiKey.value = config.apiKey || "";
+    ui.remember.checked = Boolean(persistent);
+    if (config.number) ui.number.value = config.number;
+  }
+
+  function saveConfig() {
+    const config = {
+      baseUrl: normalizedBaseURL(),
+      apiKey: ui.apiKey.value.trim(),
+      number: normalizeNumber(ui.number.value),
+    };
+    if (ui.remember.checked) {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
+      sessionStorage.removeItem(SESSION_KEY);
+    } else {
+      sessionStorage.setItem(SESSION_KEY, JSON.stringify(config));
+      localStorage.removeItem(STORAGE_KEY);
+    }
+  }
+
+  function normalizedBaseURL() {
+    return (ui.baseUrl.value.trim() || window.location.origin).replace(/\/+$/, "");
+  }
+
+  function normalizeNumber(value) {
+    return String(value || "").replace(/\D/g, "");
+  }
+
+  function callByID(callId) {
+    return state.calls.find(call => call.id === callId) || null;
+  }
+
+  function selectedCall() {
+    return callByID(state.selectedCallId);
+  }
+
+  function isTerminal(call) {
+    return !call || call.state === "ended" || call.state === "failed";
+  }
+
+  function formatPeer(peer) {
+    const value = String(peer || "");
+    return value.replace(/:\d+@/, "@").split("@")[0] || "Contato desconhecido";
+  }
+
+  function stateLabel(value) {
+    return ({
+      ringing: "Chamando",
+      connecting: "Conectando",
+      active: "Ativa",
+      ended: "Encerrada",
+      failed: "Falhou",
+      idle: "Inativa",
+    })[value] || value || "Desconhecido";
+  }
+
+  function log(message, details) {
+    const suffix = details === undefined ? "" : ` ${typeof details === "string" ? details : JSON.stringify(details)}`;
+    ui.log.textContent += `[${new Date().toLocaleTimeString()}] ${message}${suffix}\n`;
+    const lines = ui.log.textContent.split("\n");
+    if (lines.length > 180) ui.log.textContent = lines.slice(-160).join("\n");
+    ui.log.scrollTop = ui.log.scrollHeight;
+  }
+
+  function setBusy(busy) {
+    state.loading = busy;
+    [ui.save, ui.start, ui.accept, ui.reject, ui.connect, ui.hangup, ui.refresh].forEach(button => {
+      button.disabled = busy;
+    });
+  }
+
+  async function api(path, options = {}) {
+    const key = ui.apiKey.value.trim();
+    if (!key) throw new Error("Informe a API key da instância");
+    const headers = new Headers(options.headers || {});
+    headers.set("apikey", key);
+    if (options.body !== undefined && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
+    const response = await fetch(`${normalizedBaseURL()}${path}`, { ...options, headers });
+    const body = await response.json().catch(() => ({}));
+    if (!response.ok) throw new Error(body.error || body.message || `HTTP ${response.status}`);
+    return body;
+  }
+
+  function chooseDefaultCall() {
+    if (state.selectedCallId && callByID(state.selectedCallId)) return;
+    const live = [...state.calls].reverse().find(call => !isTerminal(call));
+    const latest = state.calls[state.calls.length - 1];
+    state.selectedCallId = live?.id || latest?.id || "";
+  }
+
+  function render() {
+    const incoming = state.calls.filter(call => call.direction === "incoming" && call.state === "ringing");
+    ui.badge.hidden = incoming.length === 0;
+    ui.badge.textContent = String(incoming.length);
+
+    ui.list.replaceChildren();
+    if (state.calls.length === 0) {
+      const empty = document.createElement("p");
+      empty.className = "evcall-empty";
+      empty.textContent = ui.apiKey.value.trim() ? "Nenhuma chamada registrada." : "Informe a API key para consultar.";
+      ui.list.appendChild(empty);
+    } else {
+      [...state.calls].reverse().forEach(call => {
+        const button = document.createElement("button");
+        button.type = "button";
+        button.className = `evcall-list-item${call.id === state.selectedCallId ? " selected" : ""}`;
+        const top = document.createElement("span");
+        top.className = "evcall-list-top";
+        const peer = document.createElement("strong");
+        peer.textContent = formatPeer(call.peer);
+        const status = document.createElement("span");
+        status.className = `evcall-pill state-${call.state || "unknown"}`;
+        status.textContent = stateLabel(call.state);
+        top.append(peer, status);
+        const meta = document.createElement("small");
+        meta.textContent = `${call.direction === "incoming" ? "Recebida" : "Realizada"} · ${call.video ? "vídeo" : "voz"} · ${call.id}`;
+        button.append(top, meta);
+        button.addEventListener("click", () => {
+          state.selectedCallId = call.id;
+          render();
+        });
+        ui.list.appendChild(button);
+      });
+    }
+
+    const call = selectedCall();
+    ui.current.hidden = !call;
+    if (!call) return;
+
+    ui.direction.textContent = call.direction === "incoming" ? "Chamada recebida" : "Chamada realizada";
+    ui.peer.textContent = formatPeer(call.peer);
+    ui.callState.textContent = stateLabel(call.state);
+    ui.callState.className = `evcall-state state-${call.state || "unknown"}`;
+
+    const incomingRinging = call.direction === "incoming" && call.state === "ringing";
+    const canTerminate = !isTerminal(call);
+    const mediaConnected = state.mediaCallId === call.id && Boolean(state.peer);
+    ui.accept.hidden = !incomingRinging;
+    ui.reject.hidden = !incomingRinging;
+    ui.connect.hidden = call.state !== "active" || mediaConnected;
+    ui.mute.hidden = !mediaConnected;
+    ui.hangup.hidden = !canTerminate;
+    ui.mute.textContent = state.muted ? "Ativar microfone" : "Silenciar";
+
+    if (mediaConnected) {
+      ui.mediaState.textContent = `Áudio conectado · enviados ${state.sentFrames} · recebidos ${state.receivedFrames} · descartados ${state.droppedFrames}`;
+    } else if (call.state === "active") {
+      ui.mediaState.textContent = "Chamada ativa. Conecte o microfone e o alto-falante.";
+    } else {
+      ui.mediaState.textContent = "O áudio ficará disponível quando a chamada estiver ativa.";
+    }
+  }
+
+  async function refreshStatus({ quiet = false } = {}) {
+    if (!ui.apiKey.value.trim()) {
+      ui.runtime.textContent = "Configuração necessária";
+      render();
+      return;
+    }
+    try {
+      const snapshot = await api("/call/status");
+      state.calls = Array.isArray(snapshot.calls) ? snapshot.calls : [];
+      chooseDefaultCall();
+      ui.runtime.textContent = `${snapshot.connected ? "WhatsApp conectado" : "WhatsApp desconectado"}${snapshot.instanceId ? ` · ${snapshot.instanceId}` : ""}`;
+      ui.settings.open = !snapshot.connected;
+      const mediaCall = callByID(state.mediaCallId);
+      if (state.mediaCallId && (!mediaCall || isTerminal(mediaCall))) await disconnectMedia({ notifyServer: false });
+      const autoCall = callByID(state.autoConnectCallId);
+      if (autoCall?.state === "active" && state.mediaCallId !== autoCall.id) {
+        state.autoConnectCallId = "";
+        connectMedia(autoCall.id).catch(error => log("Conexão automática do áudio falhou", error.message));
+      }
+      render();
+    } catch (error) {
+      ui.runtime.textContent = "Falha ao consultar instância";
+      if (!quiet) log("Falha ao consultar chamadas", error.message);
+    }
+  }
+
+  async function startCall() {
+    const number = normalizeNumber(ui.number.value);
+    if (number.length < 8 || number.length > 20) throw new Error("Informe o número completo com DDI");
+    ui.number.value = number;
+    saveConfig();
+    const call = await api("/call/start", {
+      method: "POST",
+      body: JSON.stringify({ number, video: false }),
+    });
+    state.selectedCallId = call.id;
+    state.autoConnectCallId = call.id;
+    log("Chamada iniciada", { callId: call.id, peer: call.peer });
+    await refreshStatus({ quiet: true });
+  }
+
+  async function acceptCall() {
+    const call = selectedCall();
+    if (!call) throw new Error("Selecione uma chamada");
+    await api(`/call/${encodeURIComponent(call.id)}/accept`, { method: "POST" });
+    state.autoConnectCallId = call.id;
+    log("Chamada aceita", call.id);
+    await refreshStatus({ quiet: true });
+  }
+
+  async function rejectCall() {
+    const call = selectedCall();
+    if (!call) throw new Error("Selecione uma chamada");
+    await api("/call/reject", {
+      method: "POST",
+      body: JSON.stringify({ callCreator: call.peer, callId: call.id }),
+    });
+    state.autoConnectCallId = "";
+    log("Chamada recusada", call.id);
+    await refreshStatus({ quiet: true });
+  }
+
+  async function terminateCall() {
+    const call = selectedCall();
+    if (!call) throw new Error("Selecione uma chamada");
+    if (state.mediaCallId === call.id) await disconnectMedia();
+    await api(`/call/${encodeURIComponent(call.id)}`, { method: "DELETE" });
+    state.autoConnectCallId = "";
+    log("Chamada encerrada", call.id);
+    await refreshStatus({ quiet: true });
+  }
+
+  function encodePCM(samples) {
+    const buffer = new ArrayBuffer(HEADER_BYTES + samples.length * 4);
+    const bytes = new Uint8Array(buffer);
+    bytes.set([0x45, 0x56, 0x50, 0x43], 0);
+    const view = new DataView(buffer);
+    view.setUint8(4, 1);
+    view.setUint8(5, 1);
+    view.setUint16(6, 0, true);
+    view.setUint32(8, PCM_RATE, true);
+    view.setUint32(12, samples.length, true);
+    for (let index = 0; index < samples.length; index++) {
+      const sample = Number.isFinite(samples[index]) ? Math.max(-1, Math.min(1, samples[index])) : 0;
+      view.setFloat32(HEADER_BYTES + index * 4, sample, true);
+    }
+    return buffer;
+  }
+
+  function decodePCM(buffer) {
+    if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < HEADER_BYTES) throw new Error("frame PCM truncado");
+    const bytes = new Uint8Array(buffer, 0, 4);
+    if (bytes[0] !== 0x45 || bytes[1] !== 0x56 || bytes[2] !== 0x50 || bytes[3] !== 0x43) throw new Error("magic PCM inválido");
+    const view = new DataView(buffer);
+    if (view.getUint8(4) !== 1 || view.getUint8(5) !== 1 || view.getUint16(6, true) !== 0) throw new Error("versão PCM incompatível");
+    if (view.getUint32(8, true) !== PCM_RATE) throw new Error("sample rate PCM incompatível");
+    const count = view.getUint32(12, true);
+    if (!count || count > PCM_FRAME_SAMPLES * 4 || buffer.byteLength !== HEADER_BYTES + count * 4) throw new Error("tamanho PCM inválido");
+    const output = new Float32Array(count);
+    for (let index = 0; index < count; index++) output[index] = view.getFloat32(HEADER_BYTES + index * 4, true);
+    return output;
+  }
+
+  async function installAudioWorklet(context) {
+    const source = `
+      class EvolutionManagerPCMProcessor extends AudioWorkletProcessor {
+        constructor(options) {
+          super();
+          this.mode = options.processorOptions.mode;
+          this.queue = [];
+          this.offset = 0;
+          this.port.onmessage = event => {
+            if (this.mode === 'playback' && event.data instanceof Float32Array) this.queue.push(event.data);
+          };
+        }
+        process(inputs, outputs) {
+          if (this.mode === 'capture') {
+            const input = inputs[0] && inputs[0][0];
+            if (input && input.length) this.port.postMessage(new Float32Array(input));
+          } else {
+            const output = outputs[0] && outputs[0][0];
+            if (output) {
+              output.fill(0);
+              let written = 0;
+              while (written < output.length && this.queue.length) {
+                const chunk = this.queue[0];
+                const count = Math.min(output.length - written, chunk.length - this.offset);
+                output.set(chunk.subarray(this.offset, this.offset + count), written);
+                written += count;
+                this.offset += count;
+                if (this.offset >= chunk.length) { this.queue.shift(); this.offset = 0; }
+              }
+            }
+          }
+          return true;
+        }
+      }
+      registerProcessor('evolution-manager-pcm', EvolutionManagerPCMProcessor);
+    `;
+    const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
+    try { await context.audioWorklet.addModule(url); } finally { URL.revokeObjectURL(url); }
+  }
+
+  function gatherComplete(connection) {
+    if (connection.iceGatheringState === "complete") return Promise.resolve();
+    return new Promise((resolve, reject) => {
+      const timeout = setTimeout(() => {
+        connection.removeEventListener("icegatheringstatechange", listener);
+        reject(new Error("timeout ao coletar candidatos ICE"));
+      }, 15000);
+      const listener = () => {
+        if (connection.iceGatheringState === "complete") {
+          clearTimeout(timeout);
+          connection.removeEventListener("icegatheringstatechange", listener);
+          resolve();
+        }
+      };
+      connection.addEventListener("icegatheringstatechange", listener);
+    });
+  }
+
+  function appendCapture(samples) {
+    const joined = new Float32Array(state.capturePending.length + samples.length);
+    joined.set(state.capturePending);
+    joined.set(samples, state.capturePending.length);
+    let offset = 0;
+    while (joined.length - offset >= PCM_FRAME_SAMPLES) {
+      const frame = joined.slice(offset, offset + PCM_FRAME_SAMPLES);
+      offset += PCM_FRAME_SAMPLES;
+      if (!state.muted && state.channel?.readyState === "open" && state.channel.bufferedAmount <= MAX_BUFFERED_AMOUNT) {
+        state.channel.send(encodePCM(frame));
+        state.sentFrames++;
+      } else if (!state.muted) {
+        state.droppedFrames++;
+      }
+    }
+    state.capturePending = joined.slice(offset);
+  }
+
+  async function startAudio() {
+    const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
+    if (!AudioContextCtor || !window.AudioWorkletNode) throw new Error("Este navegador não suporta AudioWorklet");
+    state.audioContext = new AudioContextCtor({ latencyHint: "interactive" });
+    await installAudioWorklet(state.audioContext);
+    await state.audioContext.resume();
+    state.captureResampler = new StreamingLinearResampler(state.audioContext.sampleRate, PCM_RATE);
+    state.playbackResampler = new StreamingLinearResampler(PCM_RATE, state.audioContext.sampleRate);
+
+    state.playbackNode = new AudioWorkletNode(state.audioContext, "evolution-manager-pcm", {
+      numberOfInputs: 0,
+      numberOfOutputs: 1,
+      outputChannelCount: [1],
+      processorOptions: { mode: "playback" },
+    });
+    state.playbackNode.connect(state.audioContext.destination);
+
+    state.microphoneStream = await navigator.mediaDevices.getUserMedia({
+      audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
+      video: false,
+    });
+    state.captureSource = state.audioContext.createMediaStreamSource(state.microphoneStream);
+    state.captureNode = new AudioWorkletNode(state.audioContext, "evolution-manager-pcm", {
+      numberOfInputs: 1,
+      numberOfOutputs: 0,
+      processorOptions: { mode: "capture" },
+    });
+    state.captureNode.port.onmessage = event => appendCapture(state.captureResampler.push(event.data));
+    state.captureSource.connect(state.captureNode);
+    log("Microfone e reprodução iniciados", { sampleRate: state.audioContext.sampleRate });
+  }
+
+  async function connectMedia(callId) {
+    const call = callByID(callId);
+    if (!call || call.state !== "active") throw new Error("A chamada precisa estar ativa");
+    if (!window.isSecureContext && location.hostname !== "localhost") throw new Error("O microfone exige HTTPS");
+    if (state.peer) await disconnectMedia();
+
+    state.sentFrames = 0;
+    state.receivedFrames = 0;
+    state.droppedFrames = 0;
+    state.capturePending = new Float32Array(0);
+    state.mediaCallId = callId;
+    state.peer = new RTCPeerConnection({ iceServers: [] });
+    state.channel = state.peer.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true, protocol: DATA_CHANNEL_PROTOCOL });
+    state.channel.binaryType = "arraybuffer";
+    state.channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT / 2;
+
+    state.channel.onopen = async () => {
+      log("Canal de áudio aberto", callId);
+      try {
+        await startAudio();
+        render();
+      } catch (error) {
+        log("Falha ao iniciar áudio", error.message);
+        await disconnectMedia();
+      }
+    };
+    state.channel.onclose = () => {
+      log("Canal de áudio fechado", callId);
+      if (state.mediaCallId === callId) disconnectMedia({ notifyServer: false }).catch(() => {});
+    };
+    state.channel.onerror = event => log("Erro no canal de áudio", event?.message || "DataChannel");
+    state.channel.onmessage = event => {
+      try {
+        const pcm16k = decodePCM(event.data);
+        const playback = state.playbackResampler?.push(pcm16k) || new Float32Array(0);
+        if (playback.length) state.playbackNode?.port.postMessage(playback, [playback.buffer]);
+        state.receivedFrames++;
+        if (state.receivedFrames % 10 === 0) render();
+      } catch (error) {
+        state.droppedFrames++;
+        log("Frame de áudio recebido rejeitado", error.message);
+      }
+    };
+    state.peer.onconnectionstatechange = () => {
+      log("PeerConnection", state.peer?.connectionState || "closed");
+      if (["failed", "closed"].includes(state.peer?.connectionState)) disconnectMedia({ notifyServer: false }).catch(() => {});
+    };
+
+    try {
+      await state.peer.setLocalDescription(await state.peer.createOffer());
+      await gatherComplete(state.peer);
+      const body = await api(`/call/${encodeURIComponent(callId)}/webrtc`, {
+        method: "POST",
+        body: JSON.stringify({ offer: { type: "offer", sdp: state.peer.localDescription.sdp } }),
+      });
+      state.sessionId = body.sessionId;
+      await state.peer.setRemoteDescription(body.answer);
+      log("Sessão WebRTC criada", { callId, sessionId: state.sessionId });
+      render();
+    } catch (error) {
+      await disconnectMedia({ notifyServer: false });
+      throw error;
+    }
+  }
+
+  async function disconnectMedia({ notifyServer = true } = {}) {
+    const closingSession = state.sessionId;
+    const closingCall = state.mediaCallId;
+    state.sessionId = "";
+    state.mediaCallId = "";
+
+    state.microphoneStream?.getTracks().forEach(track => track.stop());
+    state.microphoneStream = null;
+    state.captureSource?.disconnect();
+    state.captureNode?.disconnect();
+    state.playbackNode?.disconnect();
+    state.captureSource = null;
+    state.captureNode = null;
+    state.playbackNode = null;
+    if (state.audioContext) await state.audioContext.close().catch(() => {});
+    state.audioContext = null;
+    state.channel?.close();
+    state.peer?.close();
+    state.channel = null;
+    state.peer = null;
+    state.capturePending = new Float32Array(0);
+
+    if (notifyServer && closingSession && closingCall && ui.apiKey.value.trim()) {
+      await api(`/call/${encodeURIComponent(closingCall)}/webrtc/${encodeURIComponent(closingSession)}`, {
+        method: "DELETE",
+      }).catch(() => {});
+    }
+    if (closingCall) log("Áudio desconectado", { callId: closingCall, sent: state.sentFrames, received: state.receivedFrames, dropped: state.droppedFrames });
+    render();
+  }
+
+  async function runAction(action) {
+    if (state.loading) return;
+    setBusy(true);
+    try { await action(); } catch (error) { log("Operação falhou", error.message); }
+    finally { setBusy(false); render(); }
+  }
+
+  function startPolling() {
+    if (state.pollTimer) return;
+    state.pollTimer = window.setInterval(() => refreshStatus({ quiet: true }), POLL_INTERVAL_MS);
+  }
+
+  function stopPolling() {
+    if (!state.pollTimer) return;
+    clearInterval(state.pollTimer);
+    state.pollTimer = null;
+  }
+
+  function togglePanel(force) {
+    state.open = force ?? !state.open;
+    ui.panel.hidden = !state.open;
+    ui.launcher.classList.toggle("active", state.open);
+    if (state.open) {
+      startPolling();
+      refreshStatus({ quiet: false });
+      setTimeout(() => ui.apiKey.value ? ui.number.focus() : ui.apiKey.focus(), 50);
+    } else {
+      stopPolling();
+    }
+  }
+
+  ui.launcher.addEventListener("click", () => togglePanel());
+  ui.close.addEventListener("click", () => togglePanel(false));
+  ui.save.addEventListener("click", () => runAction(async () => {
+    saveConfig();
+    log("Configuração salva", { baseUrl: normalizedBaseURL(), persistent: ui.remember.checked });
+    await refreshStatus();
+  }));
+  ui.refresh.addEventListener("click", () => runAction(() => refreshStatus()));
+  ui.start.addEventListener("click", () => runAction(startCall));
+  ui.accept.addEventListener("click", () => runAction(acceptCall));
+  ui.reject.addEventListener("click", () => runAction(rejectCall));
+  ui.connect.addEventListener("click", () => runAction(() => connectMedia(selectedCall()?.id)));
+  ui.mute.addEventListener("click", () => {
+    state.muted = !state.muted;
+    log(state.muted ? "Microfone silenciado" : "Microfone ativado");
+    render();
+  });
+  ui.hangup.addEventListener("click", () => runAction(terminateCall));
+  ui.number.addEventListener("keydown", event => {
+    if (event.key === "Enter") {
+      event.preventDefault();
+      runAction(startCall);
+    }
+  });
+  ui.apiKey.addEventListener("keydown", event => {
+    if (event.key === "Enter") {
+      event.preventDefault();
+      runAction(async () => { saveConfig(); await refreshStatus(); });
+    }
+  });
+  window.addEventListener("beforeunload", () => {
+    state.microphoneStream?.getTracks().forEach(track => track.stop());
+    state.peer?.close();
+  });
+
+  loadConfig();
+  render();
+  if (ui.apiKey.value.trim()) refreshStatus({ quiet: true });
+})();

From f3dd63a549197d06d3a12a394df16728f314fc3a Mon Sep 17 00:00:00 2001
From: Jefferson Hipolito De Oliveira
 <149891602+sshturbo@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:43:23 -0300
Subject: [PATCH 156/266] feat(manager): load call panel assets

---
 manager/dist/index.html | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/manager/dist/index.html b/manager/dist/index.html
index 55588c66..1d21fd1a 100644
--- a/manager/dist/index.html
+++ b/manager/dist/index.html
@@ -7,6 +7,8 @@
     Evolution GO Manager
     
     
+    
+    
   
   
     
From 9eda0379429d2181fb54e0c5a276bca9ce59bc87 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:43:42 -0300 Subject: [PATCH 157/266] ci(manager): validate call panel assets --- .github/workflows/voip-integration.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/voip-integration.yml b/.github/workflows/voip-integration.yml index ecbe7978..cc01bebf 100644 --- a/.github/workflows/voip-integration.yml +++ b/.github/workflows/voip-integration.yml @@ -4,11 +4,13 @@ on: push: branches: - dev/astracalls-integration + - feat/manager-call-panel pull_request: paths: - "cmd/evolution-go/main.go" - "pkg/call/**" - "pkg/routes/routes.go" + - "manager/dist/**" - "go.mod" - "go.sum" - ".github/workflows/voip-integration.yml" @@ -31,6 +33,14 @@ jobs: go-version-file: go.mod cache: true + - name: Validate Manager call panel + run: | + node --check manager/dist/assets/call-manager.js + grep -q '/assets/call-manager.css' manager/dist/index.html + grep -q '/assets/call-manager.js' manager/dist/index.html + grep -q 'evolution-call-pcm' manager/dist/assets/call-manager.js + grep -q '/call/status' manager/dist/assets/call-manager.js + - name: Download dependencies run: go mod download From 46241a1ccc0228ffb1171de664a51a3a05ceeb47 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:44:13 -0300 Subject: [PATCH 158/266] docs(manager): explain integrated call panel --- docs/examples/manager-call-panel.md | 74 +++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/examples/manager-call-panel.md diff --git a/docs/examples/manager-call-panel.md b/docs/examples/manager-call-panel.md new file mode 100644 index 00000000..dd4809bb --- /dev/null +++ b/docs/examples/manager-call-panel.md @@ -0,0 +1,74 @@ +# Painel de chamadas no `/manager` + +O Manager carrega um módulo de chamadas independente do bundle React existente. Um botão de telefone aparece no canto inferior direito de `/manager` e usa as rotas autenticadas de chamadas da instância. + +## Requisitos + +- servidor compilado com `-tags=voip_pion`; +- instância WhatsApp conectada; +- página do Manager em HTTPS ou `localhost` para acesso ao microfone; +- para redes diferentes, `CALL_WEBRTC_PUBLIC_IP` e `CALL_WEBRTC_MEDIA_PORT` configurados e a porta liberada em UDP e TCP. + +## Uso + +1. Abra `https://SEU_DOMINIO/manager`. +2. Clique no botão de telefone. +3. Confirme a URL da API. Quando o Manager e a API usam o mesmo domínio, o valor padrão já é correto. +4. Informe a API key da instância. +5. Clique em **Salvar e consultar**. +6. Digite o número completo com DDI e clique em **Ligar**. +7. Quando a chamada ficar `active`, o painel tenta conectar o áudio automaticamente. Também é possível usar **Conectar áudio** manualmente. + +O painel permite: + +- iniciar chamadas de voz; +- acompanhar `ringing`, `connecting`, `active`, `ended` e `failed`; +- atender ou recusar chamadas recebidas; +- conectar microfone e alto-falante pelo DataChannel PCM; +- silenciar o microfone; +- encerrar a chamada; +- consultar contadores de frames enviados, recebidos e descartados; +- visualizar diagnóstico local. + +## Armazenamento da chave + +Por padrão, a configuração fica em `sessionStorage` e desaparece quando a sessão do navegador é encerrada. A opção **Salvar chave neste navegador** usa `localStorage` para manter a API key entre acessos. + +Não habilite a persistência em computadores compartilhados. A chave nunca é colocada na URL ou no conteúdo do log do painel. + +## Implementação + +O Manager versionado contém apenas o bundle compilado original. Por isso, o módulo foi integrado como assets isolados: + +```text +manager/dist/assets/call-manager.js +manager/dist/assets/call-manager.css +``` + +E carregado por: + +```text +manager/dist/index.html +``` + +O módulo usa o mesmo protocolo da página de teste independente: + +```text +DataChannel: evolution-call-pcm +Protocol: evcall.pcm.v1 +PCM: float32 little-endian, mono, 16 kHz +Frame nominal: 960 amostras / 60 ms +``` + +## Diagnóstico + +Se o painel mostrar `501`, a imagem foi compilada sem `voip_pion`. + +Se a chamada fica ativa, mas o canal de áudio não abre: + +- confirme o log `browser WebRTC fixed ICE endpoint enabled`; +- libere `CALL_WEBRTC_MEDIA_PORT` em UDP e TCP; +- confira os candidatos em `chrome://webrtc-internals`; +- confirme que o Manager está sendo acessado por HTTPS. + +O painel fecha tracks do microfone, AudioContext, DataChannel e PeerConnection ao desconectar o áudio. O backend também remove a sessão WebRTC quando a chamada termina, a instância desconecta ou o cliente WhatsApp é substituído. From ba83a1f97d0b1b795f777c9a92fdab18a3d78f0c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:39:29 -0300 Subject: [PATCH 159/266] fix(call): normalize relay participant device JIDs --- pkg/call/voip/media/device_jid.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 pkg/call/voip/media/device_jid.go diff --git a/pkg/call/voip/media/device_jid.go b/pkg/call/voip/media/device_jid.go new file mode 100644 index 00000000..897d839c --- /dev/null +++ b/pkg/call/voip/media/device_jid.go @@ -0,0 +1,29 @@ +package media + +import ( + "strings" + + "go.mau.fi/whatsmeow/types" +) + +// ensureDeviceJIDString normalizes account-level JIDs to the device form used +// by WhatsApp's SSRC and per-JID SRTP derivation. Relay participant entries +// normally include a device number, while call accept events may only expose +// the account-level LID/PN. +func ensureDeviceJIDString(value string) string { + at := strings.IndexByte(value, '@') + if at <= 0 { + return value + } + if colon := strings.IndexByte(value[:at], ':'); colon >= 0 { + return value + } + return value[:at] + ":0" + value[at:] +} + +func sameJIDAccount(left, right types.JID) bool { + if left.IsEmpty() || right.IsEmpty() { + return false + } + return left.User == right.User +} From 2d26815e5a2319b04463caa9a9c666f6bf7a7d4c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:39:45 -0300 Subject: [PATCH 160/266] fix(call): observe actual peer RTP SSRC --- pkg/call/voip/media/rtp_observation.go | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 pkg/call/voip/media/rtp_observation.go diff --git a/pkg/call/voip/media/rtp_observation.go b/pkg/call/voip/media/rtp_observation.go new file mode 100644 index 00000000..59816976 --- /dev/null +++ b/pkg/call/voip/media/rtp_observation.go @@ -0,0 +1,53 @@ +package media + +import ( + "encoding/binary" + "errors" + "fmt" +) + +var ErrSelfRTPFrame = errors.New("relay frame belongs to the local RTP sender") + +// relayRTPSSRC reads the clear RTP header carried inside an SRTP frame. The +// payload is encrypted, but the RTP version and SSRC remain available before +// authentication/decryption and can be used to correct relay subscriptions. +func relayRTPSSRC(frame []byte) (uint32, bool) { + if len(frame) < 12 || frame[0]&0xc0 != 0x80 { + return 0, false + } + ssrc := binary.BigEndian.Uint32(frame[8:12]) + return ssrc, ssrc != 0 +} + +// observePeerSSRC adopts the first real remote SSRC seen on the relay. The +// negotiation-derived value is only a prediction: account-level LIDs received +// in call events may omit the device suffix used by WhatsApp's SSRC derivation. +// After the first remote stream is fixed, later SSRC changes are rejected. +func (s *packetSession) observePeerSSRC(frame []byte) (previous, actual uint32, changed bool, err error) { + if s == nil { + return 0, 0, false, ErrPacketSessionNotReady + } + actual, ok := relayRTPSSRC(frame) + if !ok { + return 0, 0, false, ErrNonRTPFrame + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.srtp == nil { + return 0, 0, false, ErrPacketSessionNotReady + } + if actual == s.selfSSRC { + return s.peerSSRC, actual, false, ErrSelfRTPFrame + } + if !s.peerObserved { + previous = s.peerSSRC + s.peerSSRC = actual + s.peerObserved = true + return previous, actual, previous != actual, nil + } + if actual != s.peerSSRC { + return s.peerSSRC, actual, false, fmt.Errorf("unexpected RTP SSRC: got %d, want %d", actual, s.peerSSRC) + } + return s.peerSSRC, actual, false, nil +} From d8bff7490552dcddbc88e38dfb159f11c3a43cbd Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:42:46 -0300 Subject: [PATCH 161/266] fix(call): authenticate before adopting peer SSRC --- pkg/call/voip/media/rtp_observation.go | 30 +++++++++++--------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/pkg/call/voip/media/rtp_observation.go b/pkg/call/voip/media/rtp_observation.go index 59816976..8bd81034 100644 --- a/pkg/call/voip/media/rtp_observation.go +++ b/pkg/call/voip/media/rtp_observation.go @@ -19,11 +19,10 @@ func relayRTPSSRC(frame []byte) (uint32, bool) { return ssrc, ssrc != 0 } -// observePeerSSRC adopts the first real remote SSRC seen on the relay. The -// negotiation-derived value is only a prediction: account-level LIDs received -// in call events may omit the device suffix used by WhatsApp's SSRC derivation. -// After the first remote stream is fixed, later SSRC changes are rejected. -func (s *packetSession) observePeerSSRC(frame []byte) (previous, actual uint32, changed bool, err error) { +// peerSSRCCandidate validates whether a relay frame can belong to the remote +// stream. The first non-local SSRC is allowed as a candidate, but it is only +// committed after SRTP authentication succeeds. +func (s *packetSession) peerSSRCCandidate(frame []byte) (previous, actual uint32, first bool, err error) { if s == nil { return 0, 0, false, ErrPacketSessionNotReady } @@ -31,23 +30,20 @@ func (s *packetSession) observePeerSSRC(frame []byte) (previous, actual uint32, if !ok { return 0, 0, false, ErrNonRTPFrame } - - s.mu.Lock() - defer s.mu.Unlock() - if s.srtp == nil { - return 0, 0, false, ErrPacketSessionNotReady - } if actual == s.selfSSRC { return s.peerSSRC, actual, false, ErrSelfRTPFrame } + if s.peerObserved && actual != s.peerSSRC { + return s.peerSSRC, actual, false, fmt.Errorf("unexpected RTP SSRC: got %d, want %d", actual, s.peerSSRC) + } + return s.peerSSRC, actual, !s.peerObserved, nil +} + +func (s *packetSession) commitPeerSSRC(actual uint32) (previous uint32, changed bool) { + previous = s.peerSSRC if !s.peerObserved { - previous = s.peerSSRC s.peerSSRC = actual s.peerObserved = true - return previous, actual, previous != actual, nil - } - if actual != s.peerSSRC { - return s.peerSSRC, actual, false, fmt.Errorf("unexpected RTP SSRC: got %d, want %d", actual, s.peerSSRC) } - return s.peerSSRC, actual, false, nil + return previous, previous != actual } From 5e310f28610fb3dba390099dc4c81715bd78f741 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:43:20 -0300 Subject: [PATCH 162/266] fix(call): adopt authenticated remote RTP SSRC --- pkg/call/voip/media/packet_registry.go | 72 ++++++++++++++++++-------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/pkg/call/voip/media/packet_registry.go b/pkg/call/voip/media/packet_registry.go index ecb11157..3eccd7d3 100644 --- a/pkg/call/voip/media/packet_registry.go +++ b/pkg/call/voip/media/packet_registry.go @@ -24,11 +24,12 @@ type PacketSource interface { } type packetSession struct { - mu sync.RWMutex - srtp *SRTPSession - rtp *RTPSession - selfSSRC uint32 - peerSSRC uint32 + mu sync.RWMutex + srtp *SRTPSession + rtp *RTPSession + selfSSRC uint32 + peerSSRC uint32 + peerObserved bool } func newPacketSession(sendKeying, receiveKeying core.SRTPKeyingMaterial, selfSSRC, peerSSRC uint32) (*packetSession, error) { @@ -61,30 +62,39 @@ func (s *packetSession) protectOpus(payload []byte, durationSamples uint32, mark return s.srtp.Protect(packet) } -func (s *packetSession) unprotect(frame []byte) (*RTPPacket, error) { +func (s *packetSession) unprotect(frame []byte) (*RTPPacket, uint32, uint32, bool, error) { if s == nil { - return nil, ErrPacketSessionNotReady + return nil, 0, 0, false, ErrPacketSessionNotReady } - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() if s.srtp == nil { - return nil, ErrPacketSessionNotReady + return nil, 0, 0, false, ErrPacketSessionNotReady + } + + previous, actual, first, err := s.peerSSRCCandidate(frame) + if err != nil { + return nil, previous, actual, false, err } packet, err := s.srtp.Unprotect(frame) if err != nil { - return nil, err + return nil, previous, actual, false, err } - if packet.Header.SSRC != s.peerSSRC { + if packet.Header.SSRC != actual { got := packet.Header.SSRC packet.Wipe() - return nil, fmt.Errorf("unexpected RTP SSRC: got %d, want %d", got, s.peerSSRC) + return nil, previous, actual, false, fmt.Errorf("authenticated RTP SSRC mismatch: header=%d frame=%d", got, actual) } if packet.Header.PayloadType != core.PayloadTypeWhatsAppOpus { got := packet.Header.PayloadType packet.Wipe() - return nil, fmt.Errorf("unexpected RTP payload type: %d", got) + return nil, previous, actual, false, fmt.Errorf("unexpected RTP payload type: %d", got) } - return packet, nil + changed := false + if first { + previous, changed = s.commitPeerSSRC(actual) + } + return packet, previous, actual, changed, nil } func (s *packetSession) close() { @@ -100,14 +110,16 @@ func (s *packetSession) close() { s.rtp = nil s.selfSSRC = 0 s.peerSSRC = 0 + s.peerObserved = false } type PacketRegistry struct { - mu sync.RWMutex - source PacketSource - clients map[string]*whatsmeow.Client - sessions map[string]map[string]*packetSession - onRTP func(instanceID, callID string, packet *RTPPacket) + mu sync.RWMutex + source PacketSource + clients map[string]*whatsmeow.Client + sessions map[string]map[string]*packetSession + onRTP func(instanceID, callID string, packet *RTPPacket) + onPeerSSRC func(instanceID, callID string, previous, actual uint32) } func NewPacketRegistry(source PacketSource) *PacketRegistry { @@ -124,6 +136,12 @@ func (r *PacketRegistry) SetOnRTP(callback func(instanceID, callID string, packe r.mu.Unlock() } +func (r *PacketRegistry) SetOnPeerSSRC(callback func(instanceID, callID string, previous, actual uint32)) { + r.mu.Lock() + r.onPeerSSRC = callback + r.mu.Unlock() +} + func (r *PacketRegistry) Attach(instanceID string, client *whatsmeow.Client) { if r == nil || instanceID == "" || client == nil { return @@ -230,7 +248,19 @@ func (r *PacketRegistry) Unprotect(instanceID, callID string, frame []byte) (*RT if err != nil { return nil, err } - return session.unprotect(frame) + packet, previous, actual, changed, err := session.unprotect(frame) + if err != nil { + return nil, err + } + if changed { + r.mu.RLock() + callback := r.onPeerSSRC + r.mu.RUnlock() + if callback != nil { + callback(instanceID, callID, previous, actual) + } + } + return packet, nil } func (r *PacketRegistry) Handle(instanceID, callID string, frame []byte) error { From 591a25d71a25b5a3317bc1e306a620f2ce535c1a Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:45:39 -0300 Subject: [PATCH 163/266] fix(call): refresh relay subscription for observed SSRC --- pkg/call/voip/media/relay_subscription.go | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 pkg/call/voip/media/relay_subscription.go diff --git a/pkg/call/voip/media/relay_subscription.go b/pkg/call/voip/media/relay_subscription.go new file mode 100644 index 00000000..cfffb014 --- /dev/null +++ b/pkg/call/voip/media/relay_subscription.go @@ -0,0 +1,33 @@ +package media + +import "fmt" + +func (s *relaySession) updatePeerSSRC(callID string, ssrc uint32) error { + if s == nil || callID == "" || ssrc == 0 { + return fmt.Errorf("invalid peer SSRC update") + } + s.mu.Lock() + relay := s.transports[callID] + s.mu.Unlock() + if relay == nil { + return fmt.Errorf("relay transport for call %s is not ready", callID) + } + relay.SetSubscriptionSSRC(ssrc) + relay.ResendSubscriptions() + return nil +} + +// UpdatePeerSSRC replaces the negotiation-derived relay subscription with the +// SSRC authenticated from the first real remote RTP frame. +func (r *RelayRegistry) UpdatePeerSSRC(instanceID, callID string, ssrc uint32) error { + if r == nil { + return fmt.Errorf("relay registry is not ready") + } + r.mu.RLock() + session := r.sessions[instanceID] + r.mu.RUnlock() + if session == nil { + return fmt.Errorf("relay runtime is not attached for instance %s", instanceID) + } + return session.updatePeerSSRC(callID, ssrc) +} From 58f5eadb8c5f601851f5f1e3c0c4332183a9bab0 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:51:14 -0300 Subject: [PATCH 164/266] fix(call): prefer concrete remote relay device --- pkg/call/voip/media/device_selection.go | 49 +++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 pkg/call/voip/media/device_selection.go diff --git a/pkg/call/voip/media/device_selection.go b/pkg/call/voip/media/device_selection.go new file mode 100644 index 00000000..85228acd --- /dev/null +++ b/pkg/call/voip/media/device_selection.go @@ -0,0 +1,49 @@ +package media + +import "go.mau.fi/whatsmeow/types" + +// selectCallDeviceJIDs chooses concrete device JIDs from the relay participant +// list. The account-level peer LID and the call creator may use different user +// identifiers, so an exact peer match is preferred but the first non-local +// participant is a safer fallback than deriving media keys from user@lid. +func selectCallDeviceJIDs(participants []string, ownJID, peerJID, creatorJID types.JID) (string, string) { + selfDevice := ensureDeviceJIDString(ownJID.String()) + peerDevice := ensureDeviceJIDString(peerJID.String()) + creatorIsRemote := !creatorJID.IsEmpty() && !sameJIDAccount(creatorJID, ownJID) + + var exactPeer string + var creatorPeer string + var fallbackPeer string + for _, participant := range participants { + jid, err := types.ParseJID(participant) + if err != nil || jid.IsEmpty() { + continue + } + device := ensureDeviceJIDString(jid.String()) + if sameJIDAccount(jid, ownJID) { + selfDevice = device + continue + } + if fallbackPeer == "" { + fallbackPeer = device + } + if sameJIDAccount(jid, peerJID) { + exactPeer = device + } + if creatorIsRemote && sameJIDAccount(jid, creatorJID) { + creatorPeer = device + } + } + + switch { + case exactPeer != "": + peerDevice = exactPeer + case creatorPeer != "": + peerDevice = creatorPeer + case fallbackPeer != "": + peerDevice = fallbackPeer + case creatorIsRemote: + peerDevice = ensureDeviceJIDString(creatorJID.String()) + } + return selfDevice, peerDevice +} From 6975043bb0841be9393bd08edd04cd61071af591 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:51:55 -0300 Subject: [PATCH 165/266] fix(call): derive SRTP from concrete peer device --- pkg/call/voip/media/packet_registry.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/call/voip/media/packet_registry.go b/pkg/call/voip/media/packet_registry.go index 3eccd7d3..0e7e17d5 100644 --- a/pkg/call/voip/media/packet_registry.go +++ b/pkg/call/voip/media/packet_registry.go @@ -189,7 +189,8 @@ func (r *PacketRegistry) Prepare(instanceID, callID string) error { if err != nil || ownJID.IsEmpty() || peerJID.IsEmpty() { return fmt.Errorf("resolve RTP participants for call %s", callID) } - selfDevice, peerDevice := selectDeviceJIDs(relayData.ParticipantJIDs, ownJID, peerJID) + creatorJID, _ := types.ParseJID(state.CallCreator) + selfDevice, peerDevice := selectCallDeviceJIDs(relayData.ParticipantJIDs, ownJID, peerJID, creatorJID) selfSSRC, err := GenerateSecureSSRC(callID, selfDevice, 0) if err != nil { return err From 9a4205ffce330198e3c212e72f3fdbea936eb86c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:52:32 -0300 Subject: [PATCH 166/266] fix(call): subscribe relay with concrete peer device --- pkg/call/voip/media/relay_registry.go | 42 +++++++++++++++------------ 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/pkg/call/voip/media/relay_registry.go b/pkg/call/voip/media/relay_registry.go index 7c1b2e93..e7d346da 100644 --- a/pkg/call/voip/media/relay_registry.go +++ b/pkg/call/voip/media/relay_registry.go @@ -115,17 +115,20 @@ func (s *relaySession) start(callID string) error { return nil } state, ok := s.source.State(s.instanceID, callID) - if !ok || state == nil || state.StateData.State != core.CallStateConnecting { + if !ok || state == nil { + return fmt.Errorf("call %s has no private relay state", callID) + } + if state.StateData.State != core.CallStateConnecting { return nil } relayData, ok := s.source.RelayData(s.instanceID, callID) if !ok || relayData == nil { - return nil + return fmt.Errorf("call %s has no relay data", callID) } defer core.ZeroRelayData(relayData) configs := call_transport.BuildRelayConfigs(relayData.Endpoints) if len(configs) == 0 { - return nil + return fmt.Errorf("call %s has no usable relay endpoints", callID) } defer call_transport.ZeroRelayConfigs(configs) @@ -155,7 +158,8 @@ func (s *relaySession) start(callID string) error { if err != nil || peerJID.IsEmpty() || ownJID.IsEmpty() { return fmt.Errorf("resolve SSRC participants for call %s", callID) } - selfDevice, peerDevice := selectDeviceJIDs(relayData.ParticipantJIDs, ownJID, peerJID) + creatorJID, _ := types.ParseJID(state.CallCreator) + selfDevice, peerDevice := selectCallDeviceJIDs(relayData.ParticipantJIDs, ownJID, peerJID, creatorJID) selfSSRC, err := GenerateSecureSSRC(callID, selfDevice, 0) if err != nil { return err @@ -165,6 +169,17 @@ func (s *relaySession) start(callID string) error { return err } + s.log.Info("WhatsApp relay media participants resolved", + "instance", s.instanceID, + "call_id", callID, + "self_device", selfDevice, + "peer_device", peerDevice, + "self_ssrc", selfSSRC, + "peer_ssrc", peerSSRC, + "participants", len(relayData.ParticipantJIDs), + "relays", len(configs), + ) + relay.SetSSRC(selfSSRC) relay.SetSubscriptionSSRC(peerSSRC) relay.SetOnConnected(func(_ string, _ int) { @@ -198,22 +213,11 @@ func (s *relaySession) start(callID string) error { return nil } +// selectDeviceJIDs is retained for compatibility with existing tests and +// callers. New call setup uses selectCallDeviceJIDs so call-creator and +// non-matching LID device participants are handled correctly. func selectDeviceJIDs(participants []string, ownJID, peerJID types.JID) (string, string) { - selfDevice := ownJID.String() - peerDevice := peerJID.String() - for _, participant := range participants { - jid, err := types.ParseJID(participant) - if err != nil || jid.IsEmpty() { - continue - } - if jid.User == ownJID.User { - selfDevice = jid.String() - } - if jid.User == peerJID.User { - peerDevice = jid.String() - } - } - return selfDevice, peerDevice + return selectCallDeviceJIDs(participants, ownJID, peerJID, types.JID{}) } func (s *relaySession) remove(callID string) { From aa9b0bba6bced19dcabfbe12f721e5df50f3462c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:53:13 -0300 Subject: [PATCH 167/266] fix(call): refresh observed SSRC and log inbound media failures --- pkg/call/lifecycle/coordinator.go | 89 ++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go index 34653a30..45524dcc 100644 --- a/pkg/call/lifecycle/coordinator.go +++ b/pkg/call/lifecycle/coordinator.go @@ -5,6 +5,7 @@ package lifecycle import ( "context" "errors" + "log/slog" "sync" call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime" @@ -32,17 +33,19 @@ type Coordinator struct { onCallMediaCleanup func(instanceID, callID string) onInstanceMediaCleanup func(instanceID string) - incomingEnabled map[string]bool + incomingEnabled map[string]bool + mediaErrorReported map[string]bool } func NewCoordinator() *Coordinator { incoming := call_incoming.NewRegistry() packets := call_media.NewPacketRegistry(incoming) coordinator := &Coordinator{ - runtimes: call_runtime.NewRegistry(), - incoming: incoming, - packets: packets, - incomingEnabled: make(map[string]bool), + runtimes: call_runtime.NewRegistry(), + incoming: incoming, + packets: packets, + incomingEnabled: make(map[string]bool), + mediaErrorReported: make(map[string]bool), } coordinator.audio = call_media.NewAudioRegistry(func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { return coordinator.SendOpus(instanceID, callID, payload, durationSamples, marker) @@ -52,27 +55,47 @@ func NewCoordinator() *Coordinator { coordinator.relays.SetOnRemoved(func(instanceID, callID string) { coordinator.audio.Remove(instanceID, callID) coordinator.packets.Remove(instanceID, callID) + coordinator.clearMediaError(instanceID, callID) coordinator.notifyCallMediaCleanup(instanceID, callID) }) coordinator.relays.SetOnCleanup(func(instanceID string) { coordinator.audio.Close(instanceID) coordinator.packets.Close(instanceID) + coordinator.clearInstanceMediaErrors(instanceID) coordinator.notifyInstanceMediaCleanup(instanceID) }) coordinator.relays.SetOnConnected(func(instanceID, callID string) { if err := coordinator.packets.Prepare(instanceID, callID); err != nil { + coordinator.reportMediaError(instanceID, callID, "prepare RTP/SRTP session", err) return } if err := coordinator.audio.Prepare(instanceID, callID); err != nil { + coordinator.reportMediaError(instanceID, callID, "prepare audio codec", err) coordinator.packets.Remove(instanceID, callID) return } if runtime, ok := coordinator.runtimes.Get(instanceID); ok { runtime.Transition(callID, "", "", call_runtime.StateActive, nil, "") } + slog.Info("WhatsApp call media active", "instance", instanceID, "call_id", callID) + }) + coordinator.packets.SetOnPeerSSRC(func(instanceID, callID string, previous, actual uint32) { + if err := coordinator.relays.UpdatePeerSSRC(instanceID, callID, actual); err != nil { + coordinator.reportMediaError(instanceID, callID, "refresh relay peer SSRC", err) + return + } + slog.Info("WhatsApp peer SSRC adopted", + "instance", instanceID, + "call_id", callID, + "predicted_ssrc", previous, + "actual_ssrc", actual, + ) }) coordinator.packets.SetOnRTP(func(instanceID, callID string, packet *call_media.RTPPacket) { - _ = coordinator.audio.HandleRTP(instanceID, callID, packet) + if err := coordinator.audio.HandleRTP(instanceID, callID, packet); err != nil { + coordinator.reportMediaError(instanceID, callID, "decode WhatsApp audio", err) + return + } coordinator.mu.RLock() callback := coordinator.onRTP coordinator.mu.RUnlock() @@ -82,13 +105,56 @@ func NewCoordinator() *Coordinator { }) coordinator.relays.SetOnPacket(func(instanceID, callID string, packet []byte) { err := coordinator.packets.Handle(instanceID, callID, packet) - if errors.Is(err, call_media.ErrNonRTPFrame) || errors.Is(err, call_media.ErrPacketSessionNotReady) { + if err == nil || errors.Is(err, call_media.ErrNonRTPFrame) || errors.Is(err, call_media.ErrPacketSessionNotReady) || errors.Is(err, call_media.ErrSelfRTPFrame) { return } + coordinator.reportMediaError(instanceID, callID, "receive WhatsApp SRTP", err) }) return coordinator } +func mediaErrorKey(instanceID, callID string) string { + return instanceID + "\x00" + callID +} + +func (c *Coordinator) reportMediaError(instanceID, callID, stage string, err error) { + if c == nil || err == nil { + return + } + key := mediaErrorKey(instanceID, callID) + c.mu.Lock() + if c.mediaErrorReported[key] { + c.mu.Unlock() + return + } + c.mediaErrorReported[key] = true + c.mu.Unlock() + slog.Warn("WhatsApp inbound media failed", "instance", instanceID, "call_id", callID, "stage", stage, "err", err) +} + +func (c *Coordinator) clearMediaError(instanceID, callID string) { + if c == nil { + return + } + c.mu.Lock() + delete(c.mediaErrorReported, mediaErrorKey(instanceID, callID)) + c.mu.Unlock() +} + +func (c *Coordinator) clearInstanceMediaErrors(instanceID string) { + if c == nil { + return + } + prefix := instanceID + "\x00" + c.mu.Lock() + for key := range c.mediaErrorReported { + if len(key) >= len(prefix) && key[:len(prefix)] == prefix { + delete(c.mediaErrorReported, key) + } + } + c.mu.Unlock() +} + // AttachClient is called by the WhatsApp client lifecycle. Public call state is // always monitored. Private outgoing negotiation remains available even when // incoming offer preparation is disabled by automatic rejection settings. @@ -120,6 +186,7 @@ func (c *Coordinator) DetachClient(instanceID string) { c.relays.Close(instanceID) c.audio.Close(instanceID) c.packets.Close(instanceID) + c.clearInstanceMediaErrors(instanceID) c.notifyInstanceMediaCleanup(instanceID) c.runtimes.Remove(instanceID) c.incoming.Close(instanceID) @@ -184,7 +251,11 @@ func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID str if err := c.incoming.Accept(ctx, instanceID, callID); err != nil { return err } - go func() { _ = c.relays.Start(instanceID, callID) }() + go func() { + if err := c.relays.Start(instanceID, callID); err != nil { + c.reportMediaError(instanceID, callID, "start incoming relay", err) + } + }() return nil } @@ -195,6 +266,7 @@ func (c *Coordinator) TerminateIncoming(ctx context.Context, instanceID, callID c.relays.Remove(instanceID, callID) c.audio.Remove(instanceID, callID) c.packets.Remove(instanceID, callID) + c.clearMediaError(instanceID, callID) c.notifyCallMediaCleanup(instanceID, callID) return nil } @@ -299,6 +371,7 @@ func (c *Coordinator) RemovePrivate(instanceID, callID string) { c.audio.Remove(instanceID, callID) c.packets.Remove(instanceID, callID) c.incoming.Remove(instanceID, callID) + c.clearMediaError(instanceID, callID) c.notifyCallMediaCleanup(instanceID, callID) } From 781a8e168d1b00a1c3be0a920542af4bfffb9918 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:53:39 -0300 Subject: [PATCH 168/266] fix(call): query incoming accept acknowledgements --- pkg/call/voip/wa/socket.go | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/pkg/call/voip/wa/socket.go b/pkg/call/voip/wa/socket.go index dd959f22..144f7a40 100644 --- a/pkg/call/voip/wa/socket.go +++ b/pkg/call/voip/wa/socket.go @@ -44,13 +44,35 @@ func (s *Socket) SendNode(ctx context.Context, node waBinary.Node) error { if s.client == nil { return fmt.Errorf("nil whatsmeow client") } + // Incoming call acceptance is a request/response stanza. Registering the + // response waiter before sending mirrors AstraCalls and prevents the accept + // ACK from being lost as an unrelated event while media setup is starting. + if isCallAcceptNode(node) { + _, err := s.Query(ctx, node) + return err + } return s.dangerous().SendNode(ctx, node) } +func isCallAcceptNode(node waBinary.Node) bool { + if node.Tag == "accept" { + return true + } + for _, child := range node.GetChildren() { + if child.Tag == "accept" { + return true + } + } + return false +} + func (s *Socket) Query(ctx context.Context, node waBinary.Node) (*waBinary.Node, error) { id, _ := node.Attrs["id"].(string) if id == "" { - return nil, s.SendNode(ctx, node) + if s.client == nil { + return nil, fmt.Errorf("nil whatsmeow client") + } + return nil, s.dangerous().SendNode(ctx, node) } dangerous := s.dangerous() @@ -137,3 +159,17 @@ func (s *Socket) ResolveLIDForPN(ctx context.Context, phoneNumber types.JID) typ } return phoneNumber } + +// ResolvePNForLID converts an opaque LID back to the user's phone-number JID +// when the WhatsApp store has learned the mapping. +func (s *Socket) ResolvePNForLID(ctx context.Context, lid types.JID) types.JID { + if lid.IsEmpty() || lid.Server != types.HiddenUserServer { + return lid + } + if s.client != nil && s.client.Store != nil && s.client.Store.LIDs != nil { + if pn, err := s.client.Store.LIDs.GetPNForLID(ctx, lid.ToNonAD()); err == nil && !pn.IsEmpty() { + return pn + } + } + return lid +} From daf24301c0d6c1459c5e67b463c90cae61263ebe Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:54:36 -0300 Subject: [PATCH 169/266] fix(call): resolve LID peers and preserve phone numbers --- pkg/call/runtime/runtime.go | 140 +++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 33 deletions(-) diff --git a/pkg/call/runtime/runtime.go b/pkg/call/runtime/runtime.go index 95356772..9ecd07ac 100644 --- a/pkg/call/runtime/runtime.go +++ b/pkg/call/runtime/runtime.go @@ -1,11 +1,13 @@ package call_runtime import ( + "context" "sort" "strings" "sync" "time" + call_wa "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" "go.mau.fi/whatsmeow" waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/types" @@ -32,8 +34,7 @@ const ( DirectionOutgoing Direction = "outgoing" ) -// Call contains transport-independent call state. The AstraCalls adapter will -// update these records while the existing Evolution event producers publish them. +// Call contains transport-independent call state. type Call struct { ID string `json:"id"` Peer string `json:"peer"` @@ -54,8 +55,6 @@ type Snapshot struct { } // Runtime owns the VoIP state associated with exactly one Evolution instance. -// It deliberately reuses the instance's existing whatsmeow client so messaging -// and calls share a single authenticated WhatsApp session. type Runtime struct { mu sync.RWMutex instanceID string @@ -100,9 +99,6 @@ func (r *Runtime) AttachClient(client *whatsmeow.Client) { } handlerID := client.AddEventHandler(r.handleEvent) - - // A reconnect can race with event-handler registration. Keep the handler only - // when this client is still the currently attached one. r.mu.Lock() if r.client == client { r.eventHandlerID = handlerID @@ -119,8 +115,6 @@ func (r *Runtime) Client() *whatsmeow.Client { return r.client } -// Close detaches the runtime event handler. Media teardown will be added by the -// AstraCalls driver when its transport and WebRTC resources are ported. func (r *Runtime) Close() { r.mu.Lock() client := r.client @@ -144,6 +138,9 @@ func (r *Runtime) UpsertCall(call Call) { if call.CreatedAt.IsZero() { call.CreatedAt = current.CreatedAt } + if call.Peer == "" { + call.Peer = current.Peer + } } else if call.CreatedAt.IsZero() { call.CreatedAt = now } @@ -172,7 +169,7 @@ func (r *Runtime) Transition(callID, peer string, direction Direction, state Sta CreatedAt: now, } } - if peer != "" { + if shouldReplacePeer(call.Peer, peer) { call.Peer = peer } if direction != "" && call.Direction == "" { @@ -191,6 +188,19 @@ func (r *Runtime) Transition(callID, peer string, direction Direction, state Sta r.calls[callID] = call } +func shouldReplacePeer(current, candidate string) bool { + if candidate == "" { + return false + } + if current == "" { + return true + } + currentJID, currentErr := types.ParseJID(current) + candidateJID, candidateErr := types.ParseJID(candidate) + return currentErr == nil && candidateErr == nil && + currentJID.Server == types.HiddenUserServer && candidateJID.Server == types.DefaultUserServer +} + func (r *Runtime) Call(callID string) (Call, bool) { r.mu.RLock() defer r.mu.RUnlock() @@ -206,19 +216,24 @@ func (r *Runtime) RemoveCall(callID string) { func (r *Runtime) Snapshot() Snapshot { r.mu.RLock() - defer r.mu.RUnlock() - calls := make([]Call, 0, len(r.calls)) for _, call := range r.calls { calls = append(calls, call) } + client := r.client + instanceID := r.instanceID + connected := client != nil && client.IsConnected() + r.mu.RUnlock() + + for index := range calls { + calls[index].Peer = resolveDisplayPeer(client, calls[index].Peer) + } sort.Slice(calls, func(i, j int) bool { return calls[i].CreatedAt.Before(calls[j].CreatedAt) }) - connected := r.client != nil && r.client.IsConnected() return Snapshot{ - InstanceID: r.instanceID, + InstanceID: instanceID, Connected: connected, Calls: calls, } @@ -230,7 +245,7 @@ func (r *Runtime) handleEvent(rawEvent interface{}) { video := callNodeContainsVideo(event.Data) r.Transition( event.CallID, - callPeer(event.CallCreator, event.From), + r.eventPeer(event.CallCreator, event.From), DirectionIncoming, StateRinging, &video, @@ -240,7 +255,7 @@ func (r *Runtime) handleEvent(rawEvent interface{}) { video := strings.EqualFold(event.Media, "video") || callNodeContainsVideo(event.Data) r.Transition( event.CallID, - callPeer(event.CallCreator, event.From), + r.eventPeer(event.CallCreator, event.From), DirectionIncoming, StateRinging, &video, @@ -249,7 +264,7 @@ func (r *Runtime) handleEvent(rawEvent interface{}) { case *events.CallPreAccept: r.Transition( event.CallID, - callPeer(event.CallCreator, event.From), + r.eventPeer(event.From, event.CallCreator), DirectionOutgoing, StateConnecting, nil, @@ -258,7 +273,7 @@ func (r *Runtime) handleEvent(rawEvent interface{}) { case *events.CallAccept: r.Transition( event.CallID, - callPeer(event.CallCreator, event.From), + r.eventPeer(event.From, event.CallCreator), DirectionOutgoing, StateConnecting, nil, @@ -267,25 +282,40 @@ func (r *Runtime) handleEvent(rawEvent interface{}) { case *events.CallTransport: r.Transition( event.CallID, - callPeer(event.CallCreator, event.From), + r.eventPeer(event.From, event.CallCreator), "", StateConnecting, nil, "", ) case *events.CallReject: + call, exists := r.Call(event.CallID) + reason := "rejected" + direction := DirectionOutgoing + if exists { + direction = call.Direction + if call.Direction == DirectionIncoming { + if call.State == StateRinging { + reason = "caller_cancelled" + } else { + reason = "peer_ended" + } + } else if call.State != StateRinging { + reason = "peer_ended" + } + } r.Transition( event.CallID, - callPeer(event.CallCreator, event.From), - DirectionOutgoing, + r.eventPeer(event.CallCreator, event.From), + direction, StateEnded, nil, - "rejected", + reason, ) case *events.CallTerminate: r.Transition( event.CallID, - callPeer(event.CallCreator, event.From), + r.eventPeer(event.CallCreator, event.From), "", StateEnded, nil, @@ -298,6 +328,60 @@ func (r *Runtime) handleEvent(rawEvent interface{}) { } } +func (r *Runtime) eventPeer(primary, secondary types.JID) string { + r.mu.RLock() + client := r.client + r.mu.RUnlock() + + candidates := []types.JID{primary, secondary} + for _, candidate := range candidates { + if candidate.IsEmpty() || isOwnJID(client, candidate) { + continue + } + resolved := resolveDisplayJID(client, candidate) + if resolved.Server == types.DefaultUserServer { + return resolved.String() + } + } + for _, candidate := range candidates { + if !candidate.IsEmpty() && !isOwnJID(client, candidate) { + return candidate.ToNonAD().String() + } + } + return "" +} + +func resolveDisplayPeer(client *whatsmeow.Client, peer string) string { + jid, err := types.ParseJID(peer) + if err != nil || jid.IsEmpty() { + return peer + } + return resolveDisplayJID(client, jid).String() +} + +func resolveDisplayJID(client *whatsmeow.Client, jid types.JID) types.JID { + jid = jid.ToNonAD() + if client == nil || jid.Server != types.HiddenUserServer { + return jid + } + return call_wa.NewSocket(client).ResolvePNForLID(context.Background(), jid).ToNonAD() +} + +func isOwnJID(client *whatsmeow.Client, jid types.JID) bool { + if client == nil || client.Store == nil || jid.IsEmpty() { + return false + } + jid = jid.ToNonAD() + if client.Store.ID != nil { + ownPN := client.Store.ID.ToNonAD() + if jid.User == ownPN.User && jid.Server == ownPN.Server { + return true + } + } + ownLID := client.Store.LID.ToNonAD() + return !ownLID.IsEmpty() && jid.User == ownLID.User && jid.Server == ownLID.Server +} + func (r *Runtime) failOpenCalls(reason string) { r.mu.Lock() defer r.mu.Unlock() @@ -314,16 +398,6 @@ func (r *Runtime) failOpenCalls(reason string) { } } -func callPeer(callCreator, from types.JID) string { - if !callCreator.IsEmpty() { - return callCreator.String() - } - if !from.IsEmpty() { - return from.String() - } - return "" -} - func callNodeContainsVideo(node *waBinary.Node) bool { if node == nil { return false From 3dd2f84fd792ffd6c9bf27121f6944ac93ab355d Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:55:12 -0300 Subject: [PATCH 170/266] fix(call): drain accept ACK without delaying relay startup --- pkg/call/voip/wa/socket.go | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/pkg/call/voip/wa/socket.go b/pkg/call/voip/wa/socket.go index 144f7a40..8f7017c9 100644 --- a/pkg/call/voip/wa/socket.go +++ b/pkg/call/voip/wa/socket.go @@ -44,16 +44,37 @@ func (s *Socket) SendNode(ctx context.Context, node waBinary.Node) error { if s.client == nil { return fmt.Errorf("nil whatsmeow client") } - // Incoming call acceptance is a request/response stanza. Registering the - // response waiter before sending mirrors AstraCalls and prevents the accept - // ACK from being lost as an unrelated event while media setup is starting. + // Incoming acceptance has an ACK. Register the waiter before sending, then + // drain it asynchronously so relay startup is not delayed by the query timer. if isCallAcceptNode(node) { - _, err := s.Query(ctx, node) - return err + return s.sendQueryAsync(ctx, node) } return s.dangerous().SendNode(ctx, node) } +func (s *Socket) sendQueryAsync(ctx context.Context, node waBinary.Node) error { + id, _ := node.Attrs["id"].(string) + if id == "" { + return s.dangerous().SendNode(ctx, node) + } + dangerous := s.dangerous() + responseChannel := dangerous.WaitResponse(id) + if err := dangerous.SendNode(ctx, node); err != nil { + dangerous.CancelResponse(id, responseChannel) + return err + } + go func() { + timer := time.NewTimer(queryTimeout) + defer timer.Stop() + select { + case <-responseChannel: + case <-timer.C: + dangerous.CancelResponse(id, responseChannel) + } + }() + return nil +} + func isCallAcceptNode(node waBinary.Node) bool { if node.Tag == "accept" { return true From 963fbbd4153872cd217c8a085a6260c27b1fcb06 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:55:50 -0300 Subject: [PATCH 171/266] test(call): cover authenticated peer SSRC adoption --- pkg/call/voip/media/packet_registry_test.go | 51 +++++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/pkg/call/voip/media/packet_registry_test.go b/pkg/call/voip/media/packet_registry_test.go index 6cfc5cf1..a671c462 100644 --- a/pkg/call/voip/media/packet_registry_test.go +++ b/pkg/call/voip/media/packet_registry_test.go @@ -149,26 +149,59 @@ func TestPacketRegistryHandleInvokesCallbackAndRejectsNonRTP(t *testing.T) { } } -func TestPacketRegistryRejectsUnexpectedPeerSSRC(t *testing.T) { +func TestPacketRegistryAdoptsFirstAuthenticatedPeerSSRC(t *testing.T) { callKey := bytes.Repeat([]byte{0x7c}, 32) registry := NewPacketRegistry(&fakePacketSource{callKey: callKey}) - if err := registry.PrepareWithDevices("instance", "call", "self@lid", "peer@lid", 11, 22); err != nil { + const ( + instanceID = "instance" + callID = "call" + selfDevice = "self:1@lid" + peerDevice = "peer:2@lid" + selfSSRC = uint32(11) + predicted = uint32(22) + actual = uint32(99) + ) + if err := registry.PrepareWithDevices(instanceID, callID, selfDevice, peerDevice, selfSSRC, predicted); err != nil { t.Fatal(err) } - defer registry.Close("instance") + defer registry.Close(instanceID) - peerSend, _ := DerivePerJIDSRTPKey(callKey, "peer@lid") - peerReceive, _ := DerivePerJIDSRTPKey(callKey, "self@lid") + peerSend, _ := DerivePerJIDSRTPKey(callKey, peerDevice) + peerReceive, _ := DerivePerJIDSRTPKey(callKey, selfDevice) defer peerSend.Wipe() defer peerReceive.Wipe() peerSession, _ := NewSRTPSession(peerSend, peerReceive, 4, 4) defer peerSession.Close() - wrongRTP, _ := NewWhatsAppOpusRTPSession(99) - frame, err := peerSession.Protect(wrongRTP.CreatePacket([]byte{1}, false)) + actualRTP, _ := NewWhatsAppOpusRTPSession(actual) + frame, err := peerSession.Protect(actualRTP.CreatePacket([]byte{1}, false)) + if err != nil { + t.Fatal(err) + } + + var gotPrevious, gotActual uint32 + registry.SetOnPeerSSRC(func(_, _ string, previous, observed uint32) { + gotPrevious, gotActual = previous, observed + }) + packet, err := registry.Unprotect(instanceID, callID, frame) + if err != nil { + t.Fatalf("first authenticated SSRC should be adopted: %v", err) + } + packet.Wipe() + if gotPrevious != predicted || gotActual != actual { + t.Fatalf("unexpected SSRC callback: previous=%d actual=%d", gotPrevious, gotActual) + } + + session, err := registry.packetSession(instanceID, callID, false) if err != nil { t.Fatal(err) } - if _, err = registry.Unprotect("instance", "call", frame); err == nil { - t.Fatal("expected unexpected SSRC error") + otherFrame := make([]byte, 12) + otherFrame[0] = 0x80 + otherFrame[8] = 0 + otherFrame[9] = 0 + otherFrame[10] = 0 + otherFrame[11] = 100 + if _, _, _, err = session.peerSSRCCandidate(otherFrame); err == nil { + t.Fatal("expected later peer SSRC changes to be rejected") } } From b5acb6a7ea74ad351060bc82447b6049af2bfa0f Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:56:05 -0300 Subject: [PATCH 172/266] test(call): cover peer device fallback and normalization --- pkg/call/voip/media/device_selection_test.go | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 pkg/call/voip/media/device_selection_test.go diff --git a/pkg/call/voip/media/device_selection_test.go b/pkg/call/voip/media/device_selection_test.go new file mode 100644 index 00000000..244fef12 --- /dev/null +++ b/pkg/call/voip/media/device_selection_test.go @@ -0,0 +1,37 @@ +package media + +import ( + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestSelectCallDeviceJIDsUsesConcreteRemoteParticipant(t *testing.T) { + self, peer := selectCallDeviceJIDs( + []string{ + "15509143740569:3@lid", + "66155398054068:2@lid", + }, + types.NewJID("15509143740569", types.HiddenUserServer), + types.NewJID("75741748277476", types.HiddenUserServer), + types.NewJID("66155398054068", types.HiddenUserServer), + ) + if self != "15509143740569:3@lid" { + t.Fatalf("unexpected self device: %s", self) + } + if peer != "66155398054068:2@lid" { + t.Fatalf("expected creator participant as peer, got %s", peer) + } +} + +func TestSelectCallDeviceJIDsNormalizesAccountFallback(t *testing.T) { + self, peer := selectCallDeviceJIDs( + nil, + types.NewJID("self", types.HiddenUserServer), + types.NewJID("peer", types.HiddenUserServer), + types.JID{}, + ) + if self != "self:0@lid" || peer != "peer:0@lid" { + t.Fatalf("unexpected normalized fallbacks: self=%s peer=%s", self, peer) + } +} From fa49987cecbb8f7b9fbac7b2f8469c6d88cb2774 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:56:20 -0300 Subject: [PATCH 173/266] test(call): preserve peer number and classify caller hangup --- pkg/call/runtime/runtime_peer_test.go | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 pkg/call/runtime/runtime_peer_test.go diff --git a/pkg/call/runtime/runtime_peer_test.go b/pkg/call/runtime/runtime_peer_test.go new file mode 100644 index 00000000..3a03cb91 --- /dev/null +++ b/pkg/call/runtime/runtime_peer_test.go @@ -0,0 +1,52 @@ +package call_runtime + +import ( + "testing" + + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +func TestTransitionPreservesKnownPhoneNumber(t *testing.T) { + runtime := New("instance", nil) + runtime.Transition( + "call", + "556298612492@s.whatsapp.net", + DirectionOutgoing, + StateRinging, + nil, + "", + ) + runtime.Transition( + "call", + "75741748277476:3@lid", + DirectionOutgoing, + StateConnecting, + nil, + "", + ) + call, _ := runtime.Call("call") + if call.Peer != "556298612492@s.whatsapp.net" { + t.Fatalf("phone number was replaced by LID: %s", call.Peer) + } +} + +func TestIncomingCallRejectAfterAcceptMeansPeerEnded(t *testing.T) { + runtime := New("instance", nil) + runtime.Transition( + "call", + "556298612492@s.whatsapp.net", + DirectionIncoming, + StateConnecting, + nil, + "", + ) + runtime.handleEvent(&events.CallReject{BasicCallMeta: types.BasicCallMeta{ + CallID: "call", + CallCreator: types.NewJID("66155398054068", types.HiddenUserServer), + }}) + call, _ := runtime.Call("call") + if call.State != StateEnded || call.EndReason != "peer_ended" { + t.Fatalf("unexpected reject classification: state=%s reason=%s", call.State, call.EndReason) + } +} From 8cad9725b5ae6c4653f84b98ec6760869c2ebfb3 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:57:37 -0300 Subject: [PATCH 174/266] fix(call): preserve dialed phone number in runtime --- pkg/call/service/call_service.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/call/service/call_service.go b/pkg/call/service/call_service.go index 2e1243bf..32f05ca0 100644 --- a/pkg/call/service/call_service.go +++ b/pkg/call/service/call_service.go @@ -86,10 +86,7 @@ func (c *callService) ensureClientConnected(instanceID string) (*whatsmeow.Clien return nil, errors.New("client disconnected") } - // Calls and messaging share the same authenticated client. Public state and - // private call negotiation are attached idempotently to that client. c.coordinator.Attach(instanceID, client) - c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Client successfully validated - Connected: %v", instanceID, client.IsConnected()) return client, nil } @@ -134,7 +131,7 @@ func (c *callService) StartCall(data *StartCallStruct, instance *instance_model. video := data.Video runtime.Transition( result.CallID, - result.Peer.String(), + peer.ToNonAD().String(), call_runtime.DirectionOutgoing, call_runtime.StateRinging, &video, From cb55df07ed03e09d5c9fa7209b8c95e6a6deadbe Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:58:59 -0300 Subject: [PATCH 175/266] fix(call): resolve peer LID before outgoing termination --- pkg/call/voip/driver/signaling.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/call/voip/driver/signaling.go b/pkg/call/voip/driver/signaling.go index 68fcd961..7d3a2cd5 100644 --- a/pkg/call/voip/driver/signaling.go +++ b/pkg/call/voip/driver/signaling.go @@ -37,9 +37,7 @@ func (r *StartResult) Wipe() { r.RelayData = nil } -// SignalingDriver sends real WhatsApp call stanzas. Media transport is not yet -// attached, so a successful offer means the peer can ring and emit lifecycle -// events, not that bidirectional audio is available. +// SignalingDriver sends real WhatsApp call stanzas. type SignalingDriver struct { socket core.VoipSocket } @@ -121,7 +119,8 @@ func (d *SignalingDriver) EndOutgoing(ctx context.Context, callID string, peer t if creator.IsEmpty() { return fmt.Errorf("whatsapp client has no own JID") } - node := signaling.BuildTerminateStanza(peer, callID, creator) + resolvedPeer := d.socket.ResolveLIDForPN(ctx, peer) + node := signaling.BuildTerminateStanza(resolvedPeer, callID, creator) if err := d.socket.SendNode(ctx, node); err != nil { return fmt.Errorf("send call terminate: %w", err) } From 8f961fe9f2dba1a51d381b5bdb83db8c47975ab5 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:59:59 -0300 Subject: [PATCH 176/266] fix(call): prioritize remote call creator device --- pkg/call/voip/media/device_selection.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/call/voip/media/device_selection.go b/pkg/call/voip/media/device_selection.go index 85228acd..fdc71000 100644 --- a/pkg/call/voip/media/device_selection.go +++ b/pkg/call/voip/media/device_selection.go @@ -4,8 +4,7 @@ import "go.mau.fi/whatsmeow/types" // selectCallDeviceJIDs chooses concrete device JIDs from the relay participant // list. The account-level peer LID and the call creator may use different user -// identifiers, so an exact peer match is preferred but the first non-local -// participant is a safer fallback than deriving media keys from user@lid. +// identifiers, so the remote creator device is preferred for incoming calls. func selectCallDeviceJIDs(participants []string, ownJID, peerJID, creatorJID types.JID) (string, string) { selfDevice := ensureDeviceJIDString(ownJID.String()) peerDevice := ensureDeviceJIDString(peerJID.String()) @@ -36,10 +35,10 @@ func selectCallDeviceJIDs(participants []string, ownJID, peerJID, creatorJID typ } switch { - case exactPeer != "": - peerDevice = exactPeer case creatorPeer != "": peerDevice = creatorPeer + case exactPeer != "": + peerDevice = exactPeer case fallbackPeer != "": peerDevice = fallbackPeer case creatorIsRemote: From 1bfa3133c56a5460a0638d0a6306e320321229e5 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:20:27 -0300 Subject: [PATCH 177/266] feat(call): add post-accept media signaling stanzas --- pkg/call/voip/signaling/post_accept.go | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 pkg/call/voip/signaling/post_accept.go diff --git a/pkg/call/voip/signaling/post_accept.go b/pkg/call/voip/signaling/post_accept.go new file mode 100644 index 00000000..e7048e55 --- /dev/null +++ b/pkg/call/voip/signaling/post_accept.go @@ -0,0 +1,55 @@ +package signaling + +import ( + "fmt" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode" + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +// BuildPostAcceptTransportStanza announces the relay media path after the +// remote party accepts an outgoing call. WhatsApp clients use message type 1 +// and candidate round 1 at this stage of the negotiation. +func BuildPostAcceptTransportStanza(peer, creator types.JID, callID string) waBinary.Node { + return waBinary.Node{ + Tag: "call", + Attrs: waBinary.Attrs{ + "to": wanode.MustJID(wanode.CleanJID(peer.String())), + "id": GenerateCallStanzaID(), + }, + Content: []waBinary.Node{{ + Tag: "transport", + Attrs: waBinary.Attrs{ + "call-id": callID, + "call-creator": creator, + "transport-message-type": "1", + "p2p-cand-round": "1", + }, + Content: []waBinary.Node{{ + Tag: "net", + Attrs: waBinary.Attrs{"medium": "2", "protocol": "0"}, + }}, + }}, + } +} + +// BuildMuteV2Stanza synchronizes the initial microphone state with the remote +// WhatsApp device after media negotiation. +func BuildMuteV2Stanza(peer, creator types.JID, callID string, muteState int) waBinary.Node { + return waBinary.Node{ + Tag: "call", + Attrs: waBinary.Attrs{ + "to": peer, + "id": GenerateCallStanzaID(), + }, + Content: []waBinary.Node{{ + Tag: "mute_v2", + Attrs: waBinary.Attrs{ + "call-id": callID, + "call-creator": creator, + "mute-state": fmt.Sprintf("%d", muteState), + }, + }}, + } +} From 9c69b77f1d5b6be15e3afa6b7d1d662878368138 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:20:39 -0300 Subject: [PATCH 178/266] feat(call): send outgoing post-accept media signaling --- pkg/call/voip/media/post_accept.go | 66 ++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 pkg/call/voip/media/post_accept.go diff --git a/pkg/call/voip/media/post_accept.go b/pkg/call/voip/media/post_accept.go new file mode 100644 index 00000000..fd88e014 --- /dev/null +++ b/pkg/call/voip/media/post_accept.go @@ -0,0 +1,66 @@ +package media + +import ( + "context" + "fmt" + "time" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" + "go.mau.fi/whatsmeow/types" +) + +const postAcceptSignalingTimeout = 5 * time.Second + +// sendOutgoingPostAccept completes the signaling sequence used by WhatsApp +// after the remote party accepts an outgoing call. The relay connection may +// start in parallel; these stanzas must not block media startup. +func (s *relaySession) sendOutgoingPostAccept(callID string) { + if s == nil || s.source == nil || callID == "" { + return + } + state, ok := s.source.State(s.instanceID, callID) + if !ok || state == nil || state.Direction != core.CallDirectionOutgoing { + return + } + + s.mu.Lock() + client := s.client + s.mu.Unlock() + if client == nil { + return + } + + peer, err := types.ParseJID(state.PeerJID) + if err != nil || peer.IsEmpty() { + if err == nil { + err = fmt.Errorf("peer JID is empty") + } + s.log.Warn("WhatsApp post-accept signaling skipped", "instance", s.instanceID, "call_id", callID, "err", err) + return + } + creator, err := types.ParseJID(state.CallCreator) + if err != nil || creator.IsEmpty() { + if err == nil { + err = fmt.Errorf("creator JID is empty") + } + s.log.Warn("WhatsApp post-accept signaling skipped", "instance", s.instanceID, "call_id", callID, "err", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), postAcceptSignalingTimeout) + defer cancel() + socket := wa.NewSocket(client) + peer = socket.ResolveLIDForPN(ctx, peer) + + if err = socket.SendNode(ctx, signaling.BuildPostAcceptTransportStanza(peer, creator, callID)); err != nil { + s.log.Warn("WhatsApp post-accept transport failed", "instance", s.instanceID, "call_id", callID, "err", err) + return + } + if err = socket.SendNode(ctx, signaling.BuildMuteV2Stanza(peer, creator, callID, 0)); err != nil { + s.log.Warn("WhatsApp post-accept mute sync failed", "instance", s.instanceID, "call_id", callID, "err", err) + return + } + s.log.Info("WhatsApp post-accept media signaling sent", "instance", s.instanceID, "call_id", callID, "peer", peer.String()) +} From b319952e64d6da9cc46274dc60dc1255f992326c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:21:18 -0300 Subject: [PATCH 179/266] fix(call): retry relay subscriptions and complete accept --- pkg/call/voip/media/relay_registry.go | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pkg/call/voip/media/relay_registry.go b/pkg/call/voip/media/relay_registry.go index e7d346da..ce795b0e 100644 --- a/pkg/call/voip/media/relay_registry.go +++ b/pkg/call/voip/media/relay_registry.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "sync" + "time" call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" @@ -89,6 +90,7 @@ func (s *relaySession) handleEvent(rawEvent interface{}) { case *events.CallAccept: _ = s.source.EnsureRemoteAccepted(s.instanceID, event.CallID) s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) + go s.sendOutgoingPostAccept(event.CallID) go s.startLogged(event.CallID) case *events.CallTransport: s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) @@ -180,9 +182,14 @@ func (s *relaySession) start(callID string) error { "relays", len(configs), ) + var retryOnce sync.Once + var firstFrame sync.Once relay.SetSSRC(selfSSRC) relay.SetSubscriptionSSRC(peerSSRC) relay.SetOnConnected(func(_ string, _ int) { + retryOnce.Do(func() { + go retryRelaySubscriptions(relay) + }) if err := s.source.MarkMediaConnected(s.instanceID, callID); err != nil { s.log.Debug("ignore stale relay connection callback", "instance", s.instanceID, "call_id", callID, "err", err) return @@ -195,6 +202,20 @@ func (s *relaySession) start(callID string) error { } }) relay.SetOnReceive(func(packet []byte) { + firstFrame.Do(func() { + rtpCandidate := len(packet) >= 12 && packet[0]&0xc0 == 0x80 + payloadType := uint8(0) + if len(packet) >= 2 { + payloadType = packet[1] & 0x7f + } + s.log.Info("WhatsApp relay first inbound frame", + "instance", s.instanceID, + "call_id", callID, + "bytes", len(packet), + "rtp_candidate", rtpCandidate, + "payload_type", payloadType, + ) + }) s.mu.Lock() callback := s.onPacket s.mu.Unlock() @@ -213,6 +234,25 @@ func (s *relaySession) start(callID string) error { return nil } +func retryRelaySubscriptions(relay call_transport.RelayTransport) { + if relay == nil { + return + } + for _, delay := range []time.Duration{ + 50 * time.Millisecond, + 150 * time.Millisecond, + 500 * time.Millisecond, + 3 * time.Second, + } { + timer := time.NewTimer(delay) + <-timer.C + if !relay.HasConnection() { + return + } + relay.ResendSubscriptions() + } +} + // selectDeviceJIDs is retained for compatibility with existing tests and // callers. New call setup uses selectCallDeviceJIDs so call-creator and // non-matching LID device participants are handled correctly. From 0425ae3502eebcb57cb39b28a08b9ecf002b5311 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:22:14 -0300 Subject: [PATCH 180/266] test(call): cover post-accept signaling stanzas --- pkg/call/voip/signaling/post_accept_test.go | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 pkg/call/voip/signaling/post_accept_test.go diff --git a/pkg/call/voip/signaling/post_accept_test.go b/pkg/call/voip/signaling/post_accept_test.go new file mode 100644 index 00000000..53afa09b --- /dev/null +++ b/pkg/call/voip/signaling/post_accept_test.go @@ -0,0 +1,53 @@ +package signaling + +import ( + "testing" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode" + "go.mau.fi/whatsmeow/types" +) + +func TestBuildPostAcceptTransportStanza(t *testing.T) { + peer := types.NewJID("5511999999999", types.HiddenUserServer) + creator := types.NewJID("5511000000000", types.HiddenUserServer) + node := BuildPostAcceptTransportStanza(peer, creator, "CALL-POST") + + children := wanode.NodeChildren(&node) + if len(children) != 1 || children[0].Tag != "transport" { + t.Fatalf("unexpected transport stanza: %#v", children) + } + transport := children[0] + if wanode.AttrString(transport.Attrs, "call-id") != "CALL-POST" { + t.Fatalf("unexpected call ID: %s", wanode.AttrString(transport.Attrs, "call-id")) + } + if wanode.AttrString(transport.Attrs, "transport-message-type") != "1" { + t.Fatalf("unexpected transport type: %s", wanode.AttrString(transport.Attrs, "transport-message-type")) + } + if wanode.AttrString(transport.Attrs, "p2p-cand-round") != "1" { + t.Fatalf("unexpected candidate round: %s", wanode.AttrString(transport.Attrs, "p2p-cand-round")) + } + netChildren := wanode.NodeChildren(&transport) + if len(netChildren) != 1 || netChildren[0].Tag != "net" { + t.Fatalf("transport is missing net child: %#v", netChildren) + } + if wanode.AttrString(netChildren[0].Attrs, "protocol") != "0" { + t.Fatalf("unexpected transport protocol: %s", wanode.AttrString(netChildren[0].Attrs, "protocol")) + } +} + +func TestBuildMuteV2Stanza(t *testing.T) { + peer := types.NewJID("5511999999999", types.HiddenUserServer) + creator := types.NewJID("5511000000000", types.HiddenUserServer) + node := BuildMuteV2Stanza(peer, creator, "CALL-MUTE", 0) + + children := wanode.NodeChildren(&node) + if len(children) != 1 || children[0].Tag != "mute_v2" { + t.Fatalf("unexpected mute stanza: %#v", children) + } + if wanode.AttrString(children[0].Attrs, "mute-state") != "0" { + t.Fatalf("unexpected mute state: %s", wanode.AttrString(children[0].Attrs, "mute-state")) + } + if wanode.AttrString(children[0].Attrs, "call-id") != "CALL-MUTE" { + t.Fatalf("unexpected call ID: %s", wanode.AttrString(children[0].Attrs, "call-id")) + } +} From 0499f3658ee6f1075085ead51d40987e8b5a51ba Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:59:34 -0300 Subject: [PATCH 181/266] fix(call): derive outgoing receive key from accepted peer account --- pkg/call/voip/incoming/srtp_bridge.go | 28 +++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/pkg/call/voip/incoming/srtp_bridge.go b/pkg/call/voip/incoming/srtp_bridge.go index ea22cbee..1ddbf0c2 100644 --- a/pkg/call/voip/incoming/srtp_bridge.go +++ b/pkg/call/voip/incoming/srtp_bridge.go @@ -2,11 +2,34 @@ package incoming import ( "fmt" + "strings" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" call_media "github.com/evolution-foundation/evolution-go/pkg/call/voip/media" ) +func ensureSRTPDeviceJID(value string) string { + at := strings.IndexByte(value, '@') + if at <= 0 { + return value + } + if strings.Contains(value[:at], ":") { + return value + } + return value[:at] + ":0" + value[at:] +} + +// receiveDeviceJID keeps relay routing and SRTP key derivation separate. +// WhatsApp relay participants may contain synthetic hosted.lid devices (for +// example :99@hosted.lid) that are valid for SSRC subscription, but outgoing +// receive keys are derived from the account/device that accepted the call. +func receiveDeviceJID(material *callMaterial, relayPeerDeviceJID string) string { + if material != nil && material.state != nil && material.state.Direction == core.CallDirectionOutgoing && !material.peer.IsEmpty() { + return ensureSRTPDeviceJID(material.peer.String()) + } + return ensureSRTPDeviceJID(relayPeerDeviceJID) +} + func (s *session) deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) { if callID == "" { return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call ID is empty") @@ -22,6 +45,7 @@ func (s *session) deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID string) return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call %s has no private encryption key", callID) } callKey := append([]byte(nil), material.callKey...) + receiveJID := receiveDeviceJID(material, peerDeviceJID) s.mu.RUnlock() defer zeroBytes(callKey) @@ -29,10 +53,10 @@ func (s *session) deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID string) if err != nil { return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive send SRTP keying: %w", err) } - receiveKeying, err := call_media.DerivePerJIDSRTPKey(callKey, peerDeviceJID) + receiveKeying, err := call_media.DerivePerJIDSRTPKey(callKey, receiveJID) if err != nil { sendKeying.Wipe() - return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive receive SRTP keying: %w", err) + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive receive SRTP keying for %s: %w", receiveJID, err) } return sendKeying, receiveKeying, nil } From 51c079581e0b774b1b0bf606526f319618af408e Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:59:53 -0300 Subject: [PATCH 182/266] test(call): cover outgoing SRTP receive JID selection --- pkg/call/voip/incoming/srtp_bridge_test.go | 29 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/pkg/call/voip/incoming/srtp_bridge_test.go b/pkg/call/voip/incoming/srtp_bridge_test.go index cba3fb3f..ce3d1dd2 100644 --- a/pkg/call/voip/incoming/srtp_bridge_test.go +++ b/pkg/call/voip/incoming/srtp_bridge_test.go @@ -11,12 +11,14 @@ import ( func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) { callKey := bytes.Repeat([]byte{0x42}, 32) session := newSession(nil) - peer := types.NewJID("5511999999999", types.DefaultUserServer) - creator := types.NewJID("5511000000000", types.DefaultUserServer) + peer := types.NewJID("5511999999999", types.HiddenUserServer) + creator := types.NewJID("5511000000000", types.HiddenUserServer) session.storeOutgoing("call-1", callKey, peer, creator, false, nil) defer session.clear() - send, receive, err := session.deriveSRTPKeying("call-1", "self:1@lid", "peer:2@lid") + // The relay may expose a synthetic hosted.lid participant. For an outgoing + // call the receive key must still use the accepted peer account/device. + send, receive, err := session.deriveSRTPKeying("call-1", "self:1@lid", "5511999999999:99@hosted.lid") if err != nil { t.Fatal(err) } @@ -28,7 +30,7 @@ func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) { t.Fatal(err) } defer wantSend.Wipe() - wantReceive, err := call_media.DerivePerJIDSRTPKey(callKey, "peer:2@lid") + wantReceive, err := call_media.DerivePerJIDSRTPKey(callKey, "5511999999999:0@lid") if err != nil { t.Fatal(err) } @@ -43,7 +45,7 @@ func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) { zeroBytes(send.MasterKey) zeroBytes(send.MasterSalt) - again, againReceive, err := session.deriveSRTPKeying("call-1", "self:1@lid", "peer:2@lid") + again, againReceive, err := session.deriveSRTPKeying("call-1", "self:1@lid", "5511999999999:99@hosted.lid") if err != nil { t.Fatal(err) } @@ -54,6 +56,23 @@ func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) { } } +func TestReceiveDeviceJIDKeepsIncomingRelayParticipant(t *testing.T) { + material := &callMaterial{} + got := receiveDeviceJID(material, "5511888888888:7@lid") + if got != "5511888888888:7@lid" { + t.Fatalf("receiveDeviceJID() = %q, want relay participant", got) + } +} + +func TestEnsureSRTPDeviceJID(t *testing.T) { + if got := ensureSRTPDeviceJID("5511999999999@lid"); got != "5511999999999:0@lid" { + t.Fatalf("ensureSRTPDeviceJID() = %q", got) + } + if got := ensureSRTPDeviceJID("5511999999999:3@lid"); got != "5511999999999:3@lid" { + t.Fatalf("device JID changed: %q", got) + } +} + func TestSessionRejectsMissingSRTPMaterial(t *testing.T) { session := newSession(nil) if _, _, err := session.deriveSRTPKeying("missing", "self@lid", "peer@lid"); err == nil { From 360ecc9150ac8e8ec3761f4610871637910f51b5 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:38:58 -0300 Subject: [PATCH 183/266] fix(call): negotiate inbound SRTP key candidates --- pkg/call/voip/media/packet_registry.go | 237 +++++++++++++++++++++---- 1 file changed, 199 insertions(+), 38 deletions(-) diff --git a/pkg/call/voip/media/packet_registry.go b/pkg/call/voip/media/packet_registry.go index 0e7e17d5..0272d279 100644 --- a/pkg/call/voip/media/packet_registry.go +++ b/pkg/call/voip/media/packet_registry.go @@ -3,6 +3,7 @@ package media import ( "errors" "fmt" + "log/slog" "sync" call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" @@ -23,29 +24,66 @@ type PacketSource interface { SRTPKeying(instanceID, callID, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) } +type packetSRTPCandidate struct { + receiveJID string + session *SRTPSession +} + type packetSession struct { - mu sync.RWMutex - srtp *SRTPSession - rtp *RTPSession - selfSSRC uint32 - peerSSRC uint32 - peerObserved bool + mu sync.RWMutex + + srtpCandidates []packetSRTPCandidate + activeCandidate int + receiveObserved bool + rtp *RTPSession + selfSSRC uint32 + peerSSRC uint32 + peerObserved bool } func newPacketSession(sendKeying, receiveKeying core.SRTPKeyingMaterial, selfSSRC, peerSSRC uint32) (*packetSession, error) { + return newPacketSessionCandidates([]packetSRTPCandidateKeying{{receiveJID: "", send: sendKeying, receive: receiveKeying}}, selfSSRC, peerSSRC) +} + +type packetSRTPCandidateKeying struct { + receiveJID string + send core.SRTPKeyingMaterial + receive core.SRTPKeyingMaterial +} + +func newPacketSessionCandidates(keyings []packetSRTPCandidateKeying, selfSSRC, peerSSRC uint32) (*packetSession, error) { if selfSSRC == 0 || peerSSRC == 0 { return nil, fmt.Errorf("RTP SSRC values must be non-zero") } - srtp, err := NewSRTPSession(sendKeying, receiveKeying, core.SRTPSendAuthTagLen, core.SRTPRecvAuthTagLen) - if err != nil { - return nil, err + if len(keyings) == 0 { + return nil, fmt.Errorf("at least one SRTP receive candidate is required") + } + + candidates := make([]packetSRTPCandidate, 0, len(keyings)) + for _, keying := range keyings { + srtp, err := NewSRTPSession(keying.send, keying.receive, core.SRTPSendAuthTagLen, core.SRTPRecvAuthTagLen) + if err != nil { + for index := range candidates { + candidates[index].session.Close() + } + return nil, err + } + candidates = append(candidates, packetSRTPCandidate{receiveJID: keying.receiveJID, session: srtp}) } + rtp, err := NewWhatsAppOpusRTPSession(selfSSRC) if err != nil { - srtp.Close() + for index := range candidates { + candidates[index].session.Close() + } return nil, err } - return &packetSession{srtp: srtp, rtp: rtp, selfSSRC: selfSSRC, peerSSRC: peerSSRC}, nil + return &packetSession{ + srtpCandidates: candidates, + rtp: rtp, + selfSSRC: selfSSRC, + peerSSRC: peerSSRC, + }, nil } func (s *packetSession) protectOpus(payload []byte, durationSamples uint32, marker bool) ([]byte, error) { @@ -54,47 +92,92 @@ func (s *packetSession) protectOpus(payload []byte, durationSamples uint32, mark } s.mu.RLock() defer s.mu.RUnlock() - if s.srtp == nil || s.rtp == nil { + if len(s.srtpCandidates) == 0 || s.srtpCandidates[0].session == nil || s.rtp == nil { return nil, ErrPacketSessionNotReady } packet := s.rtp.CreatePacketWithDuration(payload, durationSamples, marker) defer packet.Wipe() - return s.srtp.Protect(packet) + return s.srtpCandidates[0].session.Protect(packet) } -func (s *packetSession) unprotect(frame []byte) (*RTPPacket, uint32, uint32, bool, error) { +func (s *packetSession) unprotect(frame []byte) (*RTPPacket, uint32, uint32, bool, string, string, bool, error) { if s == nil { - return nil, 0, 0, false, ErrPacketSessionNotReady + return nil, 0, 0, false, "", "", false, ErrPacketSessionNotReady } s.mu.Lock() defer s.mu.Unlock() - if s.srtp == nil { - return nil, 0, 0, false, ErrPacketSessionNotReady + if len(s.srtpCandidates) == 0 { + return nil, 0, 0, false, "", "", false, ErrPacketSessionNotReady } previous, actual, first, err := s.peerSSRCCandidate(frame) if err != nil { - return nil, previous, actual, false, err + return nil, previous, actual, false, "", "", false, err } - packet, err := s.srtp.Unprotect(frame) - if err != nil { - return nil, previous, actual, false, err + + order := make([]int, 0, len(s.srtpCandidates)) + if s.activeCandidate >= 0 && s.activeCandidate < len(s.srtpCandidates) { + order = append(order, s.activeCandidate) + } + for index := range s.srtpCandidates { + if index != s.activeCandidate { + order = append(order, index) + } + } + + var packet *RTPPacket + var authErrors []error + selected := -1 + for _, index := range order { + candidate := s.srtpCandidates[index] + if candidate.session == nil { + continue + } + packet, err = candidate.session.Unprotect(frame) + if err == nil { + selected = index + break + } + if !isSRTPAuthenticationFailure(err) { + return nil, previous, actual, false, "", "", false, err + } + authErrors = append(authErrors, fmt.Errorf("receive_jid=%s: %w", candidate.receiveJID, err)) + } + if selected < 0 { + return nil, previous, actual, false, "", "", false, + fmt.Errorf("SRTP authentication failed for %d receive key candidates: %w", len(authErrors), errors.Join(authErrors...)) } + if packet.Header.SSRC != actual { got := packet.Header.SSRC packet.Wipe() - return nil, previous, actual, false, fmt.Errorf("authenticated RTP SSRC mismatch: header=%d frame=%d", got, actual) + return nil, previous, actual, false, "", "", false, fmt.Errorf("authenticated RTP SSRC mismatch: header=%d frame=%d", got, actual) } if packet.Header.PayloadType != core.PayloadTypeWhatsAppOpus { got := packet.Header.PayloadType packet.Wipe() - return nil, previous, actual, false, fmt.Errorf("unexpected RTP payload type: %d", got) + return nil, previous, actual, false, "", "", false, fmt.Errorf("unexpected RTP payload type: %d", got) } - changed := false + + previousReceiveJID := "" + if s.receiveObserved && s.activeCandidate >= 0 && s.activeCandidate < len(s.srtpCandidates) { + previousReceiveJID = s.srtpCandidates[s.activeCandidate].receiveJID + } + selectedReceiveJID := s.srtpCandidates[selected].receiveJID + receiveChanged := !s.receiveObserved || selected != s.activeCandidate + s.activeCandidate = selected + s.receiveObserved = true + + ssrcChanged := false if first { - previous, changed = s.commitPeerSSRC(actual) + previous, ssrcChanged = s.commitPeerSSRC(actual) } - return packet, previous, actual, changed, nil + return packet, previous, actual, ssrcChanged, previousReceiveJID, selectedReceiveJID, receiveChanged, nil +} + +func isSRTPAuthenticationFailure(err error) bool { + var srtpErr *SRTPError + return errors.As(err, &srtpErr) && srtpErr.Type == SRTPErrAuthFailed } func (s *packetSession) close() { @@ -103,10 +186,16 @@ func (s *packetSession) close() { } s.mu.Lock() defer s.mu.Unlock() - if s.srtp != nil { - s.srtp.Close() + for index := range s.srtpCandidates { + if s.srtpCandidates[index].session != nil { + s.srtpCandidates[index].session.Close() + } + s.srtpCandidates[index].session = nil + s.srtpCandidates[index].receiveJID = "" } - s.srtp = nil + s.srtpCandidates = nil + s.activeCandidate = 0 + s.receiveObserved = false s.rtp = nil s.selfSSRC = 0 s.peerSSRC = 0 @@ -199,21 +288,78 @@ func (r *PacketRegistry) Prepare(instanceID, callID string) error { if err != nil { return err } - return r.PrepareWithDevices(instanceID, callID, selfDevice, peerDevice, selfSSRC, peerSSRC) + + receiveJIDs := receiveSRTPJIDCandidates(state, peerDevice) + return r.PrepareWithDeviceCandidates(instanceID, callID, selfDevice, receiveJIDs, selfSSRC, peerSSRC) +} + +func receiveSRTPJIDCandidates(state *call_state.Info, relayPeerDevice string) []string { + if state == nil { + return uniqueDeviceJIDs(relayPeerDevice) + } + peerAccount := ensureDeviceJIDString(state.PeerJID) + creator := "" + if state.Direction == core.CallDirectionIncoming { + creator = ensureDeviceJIDString(state.CallCreator) + return uniqueDeviceJIDs(relayPeerDevice, creator, peerAccount) + } + return uniqueDeviceJIDs(peerAccount, relayPeerDevice) +} + +func uniqueDeviceJIDs(values ...string) []string { + seen := make(map[string]struct{}, len(values)) + output := make([]string, 0, len(values)) + for _, value := range values { + value = ensureDeviceJIDString(value) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + output = append(output, value) + } + return output } func (r *PacketRegistry) PrepareWithDevices(instanceID, callID, selfDeviceJID, peerDeviceJID string, selfSSRC, peerSSRC uint32) error { + return r.PrepareWithDeviceCandidates(instanceID, callID, selfDeviceJID, []string{peerDeviceJID}, selfSSRC, peerSSRC) +} + +func (r *PacketRegistry) PrepareWithDeviceCandidates(instanceID, callID, selfDeviceJID string, receiveJIDs []string, selfSSRC, peerSSRC uint32) error { if r == nil || r.source == nil { return ErrPacketSessionNotReady } - sendKeying, receiveKeying, err := r.source.SRTPKeying(instanceID, callID, selfDeviceJID, peerDeviceJID) - if err != nil { - return err + receiveJIDs = uniqueDeviceJIDs(receiveJIDs...) + if len(receiveJIDs) == 0 { + return fmt.Errorf("call %s has no SRTP receive JID candidates", callID) } - defer sendKeying.Wipe() - defer receiveKeying.Wipe() - candidate, err := newPacketSession(sendKeying, receiveKeying, selfSSRC, peerSSRC) + keyings := make([]packetSRTPCandidateKeying, 0, len(receiveJIDs)) + for _, receiveJID := range receiveJIDs { + sendKeying, receiveKeying, err := r.source.SRTPKeying(instanceID, callID, selfDeviceJID, receiveJID) + if err != nil { + for index := range keyings { + keyings[index].send.Wipe() + keyings[index].receive.Wipe() + } + return fmt.Errorf("derive SRTP candidate %s: %w", receiveJID, err) + } + keyings = append(keyings, packetSRTPCandidateKeying{ + receiveJID: receiveJID, + send: sendKeying, + receive: receiveKeying, + }) + } + defer func() { + for index := range keyings { + keyings[index].send.Wipe() + keyings[index].receive.Wipe() + } + }() + + candidate, err := newPacketSessionCandidates(keyings, selfSSRC, peerSSRC) if err != nil { return err } @@ -230,6 +376,13 @@ func (r *PacketRegistry) PrepareWithDevices(instanceID, callID, selfDeviceJID, p if previous != nil { previous.close() } + + slog.Info("WhatsApp SRTP receive candidates prepared", + "instance", instanceID, + "call_id", callID, + "self_jid", selfDeviceJID, + "receive_jids", receiveJIDs, + ) return nil } @@ -249,11 +402,19 @@ func (r *PacketRegistry) Unprotect(instanceID, callID string, frame []byte) (*RT if err != nil { return nil, err } - packet, previous, actual, changed, err := session.unprotect(frame) + packet, previous, actual, ssrcChanged, previousReceiveJID, selectedReceiveJID, receiveChanged, err := session.unprotect(frame) if err != nil { return nil, err } - if changed { + if receiveChanged { + slog.Info("WhatsApp SRTP receive key selected", + "instance", instanceID, + "call_id", callID, + "previous_receive_jid", previousReceiveJID, + "receive_jid", selectedReceiveJID, + ) + } + if ssrcChanged { r.mu.RLock() callback := r.onPeerSSRC r.mu.RUnlock() From 2252611975d04205327d2a5c11e6048bb4aa0ba8 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:39:50 -0300 Subject: [PATCH 184/266] fix(call): start relays on preaccept and activate once --- pkg/call/voip/media/relay_registry.go | 30 ++++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/pkg/call/voip/media/relay_registry.go b/pkg/call/voip/media/relay_registry.go index ce795b0e..6f9c5bed 100644 --- a/pkg/call/voip/media/relay_registry.go +++ b/pkg/call/voip/media/relay_registry.go @@ -87,6 +87,13 @@ func (s *relaySession) usesClient(client *whatsmeow.Client) bool { func (s *relaySession) handleEvent(rawEvent interface{}) { switch event := rawEvent.(type) { + case *events.CallPreAccept: + // WhatsApp may expose usable relay data in the original offer ACK before + // the final accept. Starting here registers subscriptions early and avoids + // losing the first remote audio packets. + _ = s.source.EnsureRemoteAccepted(s.instanceID, event.CallID) + s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) + go s.startLogged(event.CallID) case *events.CallAccept: _ = s.source.EnsureRemoteAccepted(s.instanceID, event.CallID) s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) @@ -183,6 +190,7 @@ func (s *relaySession) start(callID string) error { ) var retryOnce sync.Once + var activeOnce sync.Once var firstFrame sync.Once relay.SetSSRC(selfSSRC) relay.SetSubscriptionSSRC(peerSSRC) @@ -190,16 +198,18 @@ func (s *relaySession) start(callID string) error { retryOnce.Do(func() { go retryRelaySubscriptions(relay) }) - if err := s.source.MarkMediaConnected(s.instanceID, callID); err != nil { - s.log.Debug("ignore stale relay connection callback", "instance", s.instanceID, "call_id", callID, "err", err) - return - } - s.mu.Lock() - callback := s.onConnected - s.mu.Unlock() - if callback != nil { - callback(s.instanceID, callID) - } + activeOnce.Do(func() { + if err := s.source.MarkMediaConnected(s.instanceID, callID); err != nil { + s.log.Debug("ignore stale relay connection callback", "instance", s.instanceID, "call_id", callID, "err", err) + return + } + s.mu.Lock() + callback := s.onConnected + s.mu.Unlock() + if callback != nil { + callback(s.instanceID, callID) + } + }) }) relay.SetOnReceive(func(packet []byte) { firstFrame.Do(func() { From 4c6c67c233f79b7665296be9b628349042608f86 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:40:07 -0300 Subject: [PATCH 185/266] test(call): cover SRTP receive key fallback --- .../media/packet_registry_candidates_test.go | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 pkg/call/voip/media/packet_registry_candidates_test.go diff --git a/pkg/call/voip/media/packet_registry_candidates_test.go b/pkg/call/voip/media/packet_registry_candidates_test.go new file mode 100644 index 00000000..b4c3893a --- /dev/null +++ b/pkg/call/voip/media/packet_registry_candidates_test.go @@ -0,0 +1,80 @@ +package media + +import ( + "bytes" + "testing" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" +) + +func TestPacketRegistryFallsBackToAuthenticatedReceiveJID(t *testing.T) { + callKey := bytes.Repeat([]byte{0x31}, 32) + registry := NewPacketRegistry(&fakePacketSource{callKey: callKey}) + const ( + instanceID = "instance-candidates" + callID = "call-candidates" + selfDevice = "self:3@lid" + wrongPeer = "peer:99@hosted.lid" + actualPeer = "peer:0@lid" + selfSSRC = uint32(0x10101010) + peerSSRC = uint32(0x20202020) + ) + + if err := registry.PrepareWithDeviceCandidates( + instanceID, + callID, + selfDevice, + []string{wrongPeer, actualPeer}, + selfSSRC, + peerSSRC, + ); err != nil { + t.Fatal(err) + } + defer registry.Close(instanceID) + + peerSend, err := DerivePerJIDSRTPKey(callKey, actualPeer) + if err != nil { + t.Fatal(err) + } + defer peerSend.Wipe() + peerReceive, err := DerivePerJIDSRTPKey(callKey, selfDevice) + if err != nil { + t.Fatal(err) + } + defer peerReceive.Wipe() + peerSession, err := NewSRTPSession(peerSend, peerReceive, core.SRTPRecvAuthTagLen, core.SRTPSendAuthTagLen) + if err != nil { + t.Fatal(err) + } + defer peerSession.Close() + + peerRTP, err := NewWhatsAppOpusRTPSession(peerSSRC) + if err != nil { + t.Fatal(err) + } + frame, err := peerSession.Protect(peerRTP.CreatePacket([]byte("authenticated peer audio"), true)) + if err != nil { + t.Fatal(err) + } + + packet, err := registry.Unprotect(instanceID, callID, frame) + if err != nil { + t.Fatalf("expected fallback receive key to authenticate packet: %v", err) + } + defer packet.Wipe() + if !bytes.Equal(packet.Payload, []byte("authenticated peer audio")) { + t.Fatalf("unexpected payload: %q", packet.Payload) + } + + session, err := registry.packetSession(instanceID, callID, false) + if err != nil { + t.Fatal(err) + } + session.mu.RLock() + selected := session.srtpCandidates[session.activeCandidate].receiveJID + observed := session.receiveObserved + session.mu.RUnlock() + if !observed || selected != actualPeer { + t.Fatalf("unexpected selected receive JID: observed=%v selected=%s", observed, selected) + } +} From 1ffce9d84a42185120dee0ffaaaf7f32dfe7f36d Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:43:46 -0300 Subject: [PATCH 186/266] fix(call): preconnect relays without early media activation --- pkg/call/voip/media/relay_registry.go | 110 ++++++++++++++++++++------ 1 file changed, 86 insertions(+), 24 deletions(-) diff --git a/pkg/call/voip/media/relay_registry.go b/pkg/call/voip/media/relay_registry.go index 6f9c5bed..f4e144d1 100644 --- a/pkg/call/voip/media/relay_registry.go +++ b/pkg/call/voip/media/relay_registry.go @@ -39,6 +39,9 @@ type relaySession struct { log *slog.Logger transports map[string]call_transport.RelayTransport configuring map[string]bool + started map[string]bool + connected map[string]bool + activated map[string]bool ownJID func() types.JID onConnected func(instanceID, callID string) onPacket func(instanceID, callID string, packet []byte) @@ -61,6 +64,9 @@ func newRelaySession(instanceID string, client *whatsmeow.Client, source Negotia log: log, transports: make(map[string]call_transport.RelayTransport), configuring: make(map[string]bool), + started: make(map[string]bool), + connected: make(map[string]bool), + activated: make(map[string]bool), } session.ownJID = func() types.JID { if client == nil { @@ -88,10 +94,8 @@ func (s *relaySession) usesClient(client *whatsmeow.Client) bool { func (s *relaySession) handleEvent(rawEvent interface{}) { switch event := rawEvent.(type) { case *events.CallPreAccept: - // WhatsApp may expose usable relay data in the original offer ACK before - // the final accept. Starting here registers subscriptions early and avoids - // losing the first remote audio packets. - _ = s.source.EnsureRemoteAccepted(s.instanceID, event.CallID) + // Pre-connect the relays while the peer is still ringing, but do not mark + // media active until the final CallAccept advances private state. s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) go s.startLogged(event.CallID) case *events.CallAccept: @@ -99,9 +103,11 @@ func (s *relaySession) handleEvent(rawEvent interface{}) { s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) go s.sendOutgoingPostAccept(event.CallID) go s.startLogged(event.CallID) + go s.activateIfReady(event.CallID) case *events.CallTransport: s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data) go s.startLogged(event.CallID) + go s.activateIfReady(event.CallID) case *events.CallReject: s.remove(event.CallID) case *events.CallTerminate: @@ -127,7 +133,7 @@ func (s *relaySession) start(callID string) error { if !ok || state == nil { return fmt.Errorf("call %s has no private relay state", callID) } - if state.StateData.State != core.CallStateConnecting { + if state.StateData.State != core.CallStateRinging && state.StateData.State != core.CallStateConnecting { return nil } relayData, ok := s.source.RelayData(s.instanceID, callID) @@ -142,21 +148,44 @@ func (s *relaySession) start(callID string) error { defer call_transport.ZeroRelayConfigs(configs) s.mu.Lock() + if s.started[callID] { + relay := s.transports[callID] + connected := s.connected[callID] || (relay != nil && relay.HasConnection()) + if connected { + s.connected[callID] = true + } + s.mu.Unlock() + if connected { + go s.activateIfReady(callID) + } + return nil + } if s.configuring[callID] { s.mu.Unlock() return nil } s.configuring[callID] = true - relay := s.transports[callID] - if relay == nil { - relay = s.factory(s.log) - s.transports[callID] = relay - } + s.started[callID] = true + relay := s.factory(s.log) + s.transports[callID] = relay s.mu.Unlock() + + setupSucceeded := false defer func() { s.mu.Lock() delete(s.configuring, callID) + if !setupSucceeded { + delete(s.started, callID) + delete(s.connected, callID) + delete(s.activated, callID) + if s.transports[callID] == relay { + delete(s.transports, callID) + } + } s.mu.Unlock() + if !setupSucceeded && relay != nil { + relay.Cleanup() + } }() ownJID := types.JID{} @@ -190,26 +219,17 @@ func (s *relaySession) start(callID string) error { ) var retryOnce sync.Once - var activeOnce sync.Once var firstFrame sync.Once relay.SetSSRC(selfSSRC) relay.SetSubscriptionSSRC(peerSSRC) relay.SetOnConnected(func(_ string, _ int) { + s.mu.Lock() + s.connected[callID] = true + s.mu.Unlock() retryOnce.Do(func() { go retryRelaySubscriptions(relay) }) - activeOnce.Do(func() { - if err := s.source.MarkMediaConnected(s.instanceID, callID); err != nil { - s.log.Debug("ignore stale relay connection callback", "instance", s.instanceID, "call_id", callID, "err", err) - return - } - s.mu.Lock() - callback := s.onConnected - s.mu.Unlock() - if callback != nil { - callback(s.instanceID, callID) - } - }) + go s.activateIfReady(callID) }) relay.SetOnReceive(func(packet []byte) { firstFrame.Do(func() { @@ -236,14 +256,50 @@ func (s *relaySession) start(callID string) error { if err := relay.ConfigureRelays(configs); err != nil { if errors.Is(err, call_transport.ErrSCTPUnavailable) { - s.remove(callID) return nil } return fmt.Errorf("configure relays for call %s: %w", callID, err) } + setupSucceeded = true + if relay.HasConnection() { + s.mu.Lock() + s.connected[callID] = true + s.mu.Unlock() + go s.activateIfReady(callID) + } return nil } +func (s *relaySession) activateIfReady(callID string) { + if s == nil || s.source == nil || callID == "" { + return + } + state, ok := s.source.State(s.instanceID, callID) + if !ok || state == nil || state.StateData.State != core.CallStateConnecting { + return + } + + s.mu.Lock() + if !s.connected[callID] || s.activated[callID] { + s.mu.Unlock() + return + } + s.activated[callID] = true + callback := s.onConnected + s.mu.Unlock() + + if err := s.source.MarkMediaConnected(s.instanceID, callID); err != nil { + s.mu.Lock() + s.activated[callID] = false + s.mu.Unlock() + s.log.Debug("defer WhatsApp media activation", "instance", s.instanceID, "call_id", callID, "err", err) + return + } + if callback != nil { + callback(s.instanceID, callID) + } +} + func retryRelaySubscriptions(relay call_transport.RelayTransport) { if relay == nil { return @@ -275,6 +331,9 @@ func (s *relaySession) remove(callID string) { relay := s.transports[callID] delete(s.transports, callID) delete(s.configuring, callID) + delete(s.started, callID) + delete(s.connected, callID) + delete(s.activated, callID) callback := s.onRemoved s.mu.Unlock() if relay != nil { @@ -293,6 +352,9 @@ func (s *relaySession) cleanup() { delete(s.transports, callID) } s.configuring = make(map[string]bool) + s.started = make(map[string]bool) + s.connected = make(map[string]bool) + s.activated = make(map[string]bool) callback := s.onCleanup s.mu.Unlock() for _, relay := range transports { From d200eddc5ac4be386bf64aa89c213729ade0c1f1 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:44:48 -0300 Subject: [PATCH 187/266] test(call): cover safe relay preconnection --- pkg/call/voip/media/relay_registry_test.go | 133 +++++++++++++-------- 1 file changed, 85 insertions(+), 48 deletions(-) diff --git a/pkg/call/voip/media/relay_registry_test.go b/pkg/call/voip/media/relay_registry_test.go index c8074f28..f90253e4 100644 --- a/pkg/call/voip/media/relay_registry_test.go +++ b/pkg/call/voip/media/relay_registry_test.go @@ -56,6 +56,7 @@ type fakeRelayTransport struct { onReceive func([]byte) configureErr error cleaned bool + connected bool } func (f *fakeRelayTransport) SetSSRC(ssrc uint32) { f.ssrc = ssrc } @@ -68,9 +69,47 @@ func (f *fakeRelayTransport) ConfigureRelays(configs []call_transport.RelayConfi return f.configureErr } func (f *fakeRelayTransport) Broadcast([]byte) error { return nil } -func (f *fakeRelayTransport) HasConnection() bool { return false } -func (f *fakeRelayTransport) ConnectedCount() int { return 0 } -func (f *fakeRelayTransport) Cleanup() { f.cleaned = true } +func (f *fakeRelayTransport) HasConnection() bool { return f.connected } +func (f *fakeRelayTransport) ConnectedCount() int { + if f.connected { + return 1 + } + return 0 +} +func (f *fakeRelayTransport) Cleanup() { f.cleaned = true } + +func newTestRelaySession(source *fakeNegotiationSource, transport *fakeRelayTransport) *relaySession { + return &relaySession{ + instanceID: "instance-1", + source: source, + factory: func(*slog.Logger) call_transport.RelayTransport { return transport }, + log: slog.Default(), + transports: make(map[string]call_transport.RelayTransport), + configuring: make(map[string]bool), + started: make(map[string]bool), + connected: make(map[string]bool), + activated: make(map[string]bool), + ownJID: func() types.JID { + return types.NewJID("5511999999999", types.DefaultUserServer) + }, + } +} + +func testRelayData() *core.RelayData { + return &core.RelayData{ + Endpoints: []core.RelayEndpoint{{ + IP: "203.0.113.10", + Port: 3480, + Protocol: 0, + Key: "relay-password", + RawToken: []byte{1, 2, 3}, + }}, + ParticipantJIDs: []string{ + "5511999999999:7@s.whatsapp.net", + "5511888888888:9@s.whatsapp.net", + }, + } +} func TestRelaySessionConfiguresTransportAndMarksMediaConnected(t *testing.T) { state := call_state.NewOutgoing( @@ -86,34 +125,9 @@ func TestRelaySessionConfiguresTransportAndMarksMediaConnected(t *testing.T) { t.Fatal(err) } - source := &fakeNegotiationSource{ - state: state, - relayData: &core.RelayData{ - Endpoints: []core.RelayEndpoint{{ - IP: "203.0.113.10", - Port: 3480, - Protocol: 0, - Key: "relay-password", - RawToken: []byte{1, 2, 3}, - }}, - ParticipantJIDs: []string{ - "5511999999999:7@s.whatsapp.net", - "5511888888888:9@s.whatsapp.net", - }, - }, - } + source := &fakeNegotiationSource{state: state, relayData: testRelayData()} fakeTransport := &fakeRelayTransport{} - session := &relaySession{ - instanceID: "instance-1", - source: source, - factory: func(*slog.Logger) call_transport.RelayTransport { return fakeTransport }, - log: slog.Default(), - transports: make(map[string]call_transport.RelayTransport), - configuring: make(map[string]bool), - ownJID: func() types.JID { - return types.NewJID("5511999999999", types.DefaultUserServer) - }, - } + session := newTestRelaySession(source, fakeTransport) connectedCallback := 0 session.onConnected = func(_, _ string) { connectedCallback++ } @@ -129,9 +143,46 @@ func TestRelaySessionConfiguresTransportAndMarksMediaConnected(t *testing.T) { if fakeTransport.onConnected == nil { t.Fatal("connected callback was not installed") } + fakeTransport.connected = true + fakeTransport.onConnected("203.0.113.10", 3480) + fakeTransport.onConnected("203.0.113.11", 3480) + if source.state.StateData.State != core.CallStateActive || source.connected != 1 || connectedCallback != 1 { + t.Fatalf("media connection was not propagated once: state=%s source=%d callback=%d", source.state.StateData.State, source.connected, connectedCallback) + } +} + +func TestRelaySessionPreconnectsWithoutActivatingBeforeAccept(t *testing.T) { + state := call_state.NewOutgoing( + "call-preconnect", + "5511888888888:2@s.whatsapp.net", + "5511999999999:1@s.whatsapp.net", + core.CallMediaTypeAudio, + ) + if err := state.Apply(call_state.Transition{Type: call_state.TransitionOfferSent}); err != nil { + t.Fatal(err) + } + + source := &fakeNegotiationSource{state: state, relayData: testRelayData()} + fakeTransport := &fakeRelayTransport{} + session := newTestRelaySession(source, fakeTransport) + connectedCallback := 0 + session.onConnected = func(_, _ string) { connectedCallback++ } + + if err := session.start("call-preconnect"); err != nil { + t.Fatal(err) + } + fakeTransport.connected = true fakeTransport.onConnected("203.0.113.10", 3480) + if source.state.StateData.State != core.CallStateRinging || source.connected != 0 || connectedCallback != 0 { + t.Fatalf("preconnect activated media too early: state=%s source=%d callback=%d", source.state.StateData.State, source.connected, connectedCallback) + } + + if err := source.EnsureRemoteAccepted("instance-1", "call-preconnect"); err != nil { + t.Fatal(err) + } + session.activateIfReady("call-preconnect") if source.state.StateData.State != core.CallStateActive || source.connected != 1 || connectedCallback != 1 { - t.Fatalf("media connection was not propagated: state=%s source=%d callback=%d", source.state.StateData.State, source.connected, connectedCallback) + t.Fatalf("media was not activated after accept: state=%s source=%d callback=%d", source.state.StateData.State, source.connected, connectedCallback) } } @@ -147,17 +198,7 @@ func TestRelaySessionTreatsDisabledTransportAsNoop(t *testing.T) { }}}, } fakeTransport := &fakeRelayTransport{configureErr: call_transport.ErrSCTPUnavailable} - session := &relaySession{ - instanceID: "instance-1", - source: source, - factory: func(*slog.Logger) call_transport.RelayTransport { return fakeTransport }, - log: slog.Default(), - transports: make(map[string]call_transport.RelayTransport), - configuring: make(map[string]bool), - ownJID: func() types.JID { - return types.NewJID("5511999999999", types.DefaultUserServer) - }, - } + session := newTestRelaySession(source, fakeTransport) if err := session.start("call-2"); err != nil { t.Fatal(err) } @@ -179,12 +220,8 @@ func TestRelaySessionReturnsRealConfigurationErrors(t *testing.T) { }}}, } expected := errors.New("setup failed") - session := &relaySession{ - instanceID: "instance-1", source: source, - factory: func(*slog.Logger) call_transport.RelayTransport { return &fakeRelayTransport{configureErr: expected} }, - log: slog.Default(), transports: make(map[string]call_transport.RelayTransport), configuring: make(map[string]bool), - ownJID: func() types.JID { return types.NewJID("5511999999999", types.DefaultUserServer) }, - } + fakeTransport := &fakeRelayTransport{configureErr: expected} + session := newTestRelaySession(source, fakeTransport) if err := session.start("call-3"); !errors.Is(err, expected) { t.Fatalf("expected setup error, got %v", err) } From f02adeaf78498f7c585a6815b7485cd668710c69 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:46:40 -0300 Subject: [PATCH 188/266] fix(call): serialize relay media activation --- pkg/call/voip/media/relay_registry.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pkg/call/voip/media/relay_registry.go b/pkg/call/voip/media/relay_registry.go index f4e144d1..1912bbf6 100644 --- a/pkg/call/voip/media/relay_registry.go +++ b/pkg/call/voip/media/relay_registry.go @@ -156,7 +156,7 @@ func (s *relaySession) start(callID string) error { } s.mu.Unlock() if connected { - go s.activateIfReady(callID) + s.activateIfReady(callID) } return nil } @@ -229,7 +229,7 @@ func (s *relaySession) start(callID string) error { retryOnce.Do(func() { go retryRelaySubscriptions(relay) }) - go s.activateIfReady(callID) + s.activateIfReady(callID) }) relay.SetOnReceive(func(packet []byte) { firstFrame.Do(func() { @@ -265,7 +265,7 @@ func (s *relaySession) start(callID string) error { s.mu.Lock() s.connected[callID] = true s.mu.Unlock() - go s.activateIfReady(callID) + s.activateIfReady(callID) } return nil } @@ -274,11 +274,10 @@ func (s *relaySession) activateIfReady(callID string) { if s == nil || s.source == nil || callID == "" { return } - state, ok := s.source.State(s.instanceID, callID) - if !ok || state == nil || state.StateData.State != core.CallStateConnecting { - return - } + // Reserve activation before consulting mutable call state. Multiple relays + // may connect at nearly the same time, but only one goroutine may transition + // and notify the media pipeline. s.mu.Lock() if !s.connected[callID] || s.activated[callID] { s.mu.Unlock() @@ -288,6 +287,13 @@ func (s *relaySession) activateIfReady(callID string) { callback := s.onConnected s.mu.Unlock() + state, ok := s.source.State(s.instanceID, callID) + if !ok || state == nil || state.StateData.State != core.CallStateConnecting { + s.mu.Lock() + s.activated[callID] = false + s.mu.Unlock() + return + } if err := s.source.MarkMediaConnected(s.instanceID, callID); err != nil { s.mu.Lock() s.activated[callID] = false From 850e5a8beaa68de4677926081b3cf27f2c7e615f Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:49:32 -0300 Subject: [PATCH 189/266] feat(call): add WhatsApp RTP DEBE extension --- pkg/call/voip/media/rtp.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/call/voip/media/rtp.go b/pkg/call/voip/media/rtp.go index 9ea0f867..ba5bb5f7 100644 --- a/pkg/call/voip/media/rtp.go +++ b/pkg/call/voip/media/rtp.go @@ -14,6 +14,11 @@ const ( rtpVersion uint8 = 2 rtpMinHeaderSize = 12 maxCSRCCount = 15 + + // WhatsApp audio RTP uses the RFC 5285 one-byte extension profile even when + // the current packet carries no extension elements. Native clients include + // this empty DEBE block on outbound voice packets. + whatsAppRTPDEBEProfile uint16 = 0xbede ) type RTPHeader struct { @@ -230,6 +235,8 @@ type RTPSession struct { sequenceNumber uint16 timestamp uint32 samplesPerPacket uint32 + extension bool + extensionProfile uint16 } func NewRTPSession(ssrc uint32, payloadType uint8, samplesPerPacket uint32) (*RTPSession, error) { @@ -260,7 +267,13 @@ func NewRTPSession(ssrc uint32, payloadType uint8, samplesPerPacket uint32) (*RT } func NewWhatsAppOpusRTPSession(ssrc uint32) (*RTPSession, error) { - return NewRTPSession(ssrc, core.PayloadTypeWhatsAppOpus, 960) + session, err := NewRTPSession(ssrc, core.PayloadTypeWhatsAppOpus, 960) + if err != nil { + return nil, err + } + session.extension = true + session.extensionProfile = whatsAppRTPDEBEProfile + return session, nil } func (s *RTPSession) CreatePacket(payload []byte, marker bool) *RTPPacket { @@ -271,6 +284,8 @@ func (s *RTPSession) CreatePacketWithDuration(payload []byte, durationSamples ui s.mu.Lock() header := NewRTPHeader(s.payloadType, s.sequenceNumber, s.timestamp, s.ssrc) header.Marker = marker + header.Extension = s.extension + header.ExtensionProfile = s.extensionProfile s.sequenceNumber++ s.timestamp += durationSamples s.mu.Unlock() From 0b33a33a53fd6a8fb05129cebc127687f6ce74d2 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:50:02 -0300 Subject: [PATCH 190/266] test(call): cover WhatsApp RTP extension --- .../voip/media/rtp_whatsapp_extension_test.go | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 pkg/call/voip/media/rtp_whatsapp_extension_test.go diff --git a/pkg/call/voip/media/rtp_whatsapp_extension_test.go b/pkg/call/voip/media/rtp_whatsapp_extension_test.go new file mode 100644 index 00000000..fc7d2192 --- /dev/null +++ b/pkg/call/voip/media/rtp_whatsapp_extension_test.go @@ -0,0 +1,29 @@ +package media + +import "testing" + +func TestWhatsAppOpusSessionUsesDEBEExtension(t *testing.T) { + session, err := NewWhatsAppOpusRTPSession(1234) + if err != nil { + t.Fatal(err) + } + packet := session.CreatePacket([]byte{1, 2, 3}, true) + if packet.Header == nil || !packet.Header.Extension { + t.Fatal("expected RTP extension") + } + if packet.Header.ExtensionProfile != whatsAppRTPDEBEProfile { + t.Fatalf("unexpected profile: %x", packet.Header.ExtensionProfile) + } + encoded, err := packet.Marshal() + if err != nil { + t.Fatal(err) + } + decoded, err := ParseRTPPacket(encoded) + if err != nil { + t.Fatal(err) + } + defer decoded.Wipe() + if !decoded.Header.Extension || decoded.Header.ExtensionProfile != whatsAppRTPDEBEProfile { + t.Fatal("extension did not survive RTP round trip") + } +} From a1796bb75060c667860fca241f9f59320c931fc1 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:51:00 -0300 Subject: [PATCH 191/266] fix(call): preserve SRTP receive candidate JIDs --- pkg/call/voip/incoming/srtp_bridge.go | 28 ++------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/pkg/call/voip/incoming/srtp_bridge.go b/pkg/call/voip/incoming/srtp_bridge.go index 1ddbf0c2..f35b3f96 100644 --- a/pkg/call/voip/incoming/srtp_bridge.go +++ b/pkg/call/voip/incoming/srtp_bridge.go @@ -2,34 +2,11 @@ package incoming import ( "fmt" - "strings" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" call_media "github.com/evolution-foundation/evolution-go/pkg/call/voip/media" ) -func ensureSRTPDeviceJID(value string) string { - at := strings.IndexByte(value, '@') - if at <= 0 { - return value - } - if strings.Contains(value[:at], ":") { - return value - } - return value[:at] + ":0" + value[at:] -} - -// receiveDeviceJID keeps relay routing and SRTP key derivation separate. -// WhatsApp relay participants may contain synthetic hosted.lid devices (for -// example :99@hosted.lid) that are valid for SSRC subscription, but outgoing -// receive keys are derived from the account/device that accepted the call. -func receiveDeviceJID(material *callMaterial, relayPeerDeviceJID string) string { - if material != nil && material.state != nil && material.state.Direction == core.CallDirectionOutgoing && !material.peer.IsEmpty() { - return ensureSRTPDeviceJID(material.peer.String()) - } - return ensureSRTPDeviceJID(relayPeerDeviceJID) -} - func (s *session) deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) { if callID == "" { return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call ID is empty") @@ -45,7 +22,6 @@ func (s *session) deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID string) return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call %s has no private encryption key", callID) } callKey := append([]byte(nil), material.callKey...) - receiveJID := receiveDeviceJID(material, peerDeviceJID) s.mu.RUnlock() defer zeroBytes(callKey) @@ -53,10 +29,10 @@ func (s *session) deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID string) if err != nil { return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive send SRTP keying: %w", err) } - receiveKeying, err := call_media.DerivePerJIDSRTPKey(callKey, receiveJID) + receiveKeying, err := call_media.DerivePerJIDSRTPKey(callKey, peerDeviceJID) if err != nil { sendKeying.Wipe() - return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive receive SRTP keying for %s: %w", receiveJID, err) + return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive receive SRTP keying for %s: %w", peerDeviceJID, err) } return sendKeying, receiveKeying, nil } From 8b3faad7ee6e7a73e7fe9683c879c5062eda9dea Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:52:30 -0300 Subject: [PATCH 192/266] test(call): align private SRTP derivation with candidates --- pkg/call/voip/incoming/srtp_bridge_test.go | 26 ++++------------------ 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/pkg/call/voip/incoming/srtp_bridge_test.go b/pkg/call/voip/incoming/srtp_bridge_test.go index ce3d1dd2..eda149e6 100644 --- a/pkg/call/voip/incoming/srtp_bridge_test.go +++ b/pkg/call/voip/incoming/srtp_bridge_test.go @@ -16,9 +16,8 @@ func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) { session.storeOutgoing("call-1", callKey, peer, creator, false, nil) defer session.clear() - // The relay may expose a synthetic hosted.lid participant. For an outgoing - // call the receive key must still use the accepted peer account/device. - send, receive, err := session.deriveSRTPKeying("call-1", "self:1@lid", "5511999999999:99@hosted.lid") + const receiveCandidate = "5511999999999:99@hosted.lid" + send, receive, err := session.deriveSRTPKeying("call-1", "self:1@lid", receiveCandidate) if err != nil { t.Fatal(err) } @@ -30,7 +29,7 @@ func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) { t.Fatal(err) } defer wantSend.Wipe() - wantReceive, err := call_media.DerivePerJIDSRTPKey(callKey, "5511999999999:0@lid") + wantReceive, err := call_media.DerivePerJIDSRTPKey(callKey, receiveCandidate) if err != nil { t.Fatal(err) } @@ -45,7 +44,7 @@ func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) { zeroBytes(send.MasterKey) zeroBytes(send.MasterSalt) - again, againReceive, err := session.deriveSRTPKeying("call-1", "self:1@lid", "5511999999999:99@hosted.lid") + again, againReceive, err := session.deriveSRTPKeying("call-1", "self:1@lid", receiveCandidate) if err != nil { t.Fatal(err) } @@ -56,23 +55,6 @@ func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) { } } -func TestReceiveDeviceJIDKeepsIncomingRelayParticipant(t *testing.T) { - material := &callMaterial{} - got := receiveDeviceJID(material, "5511888888888:7@lid") - if got != "5511888888888:7@lid" { - t.Fatalf("receiveDeviceJID() = %q, want relay participant", got) - } -} - -func TestEnsureSRTPDeviceJID(t *testing.T) { - if got := ensureSRTPDeviceJID("5511999999999@lid"); got != "5511999999999:0@lid" { - t.Fatalf("ensureSRTPDeviceJID() = %q", got) - } - if got := ensureSRTPDeviceJID("5511999999999:3@lid"); got != "5511999999999:3@lid" { - t.Fatalf("device JID changed: %q", got) - } -} - func TestSessionRejectsMissingSRTPMaterial(t *testing.T) { session := newSession(nil) if _, _, err := session.deriveSRTPKeying("missing", "self@lid", "peer@lid"); err == nil { From df7a93a66125d9523977cda56fdd101711009fd3 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:55:10 -0300 Subject: [PATCH 193/266] feat(call): capture peer call keys from accept --- pkg/call/voip/media/peer_call_key.go | 273 +++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 pkg/call/voip/media/peer_call_key.go diff --git a/pkg/call/voip/media/peer_call_key.go b/pkg/call/voip/media/peer_call_key.go new file mode 100644 index 00000000..9c994af6 --- /dev/null +++ b/pkg/call/voip/media/peer_call_key.go @@ -0,0 +1,273 @@ +package media + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +const peerCallKeyDecryptTimeout = 5 * time.Second + +type storedPeerCallKey struct { + key []byte + peer types.JID +} + +type peerCallKeyObserver struct { + client *whatsmeow.Client + handlerID uint32 + keys map[string]storedPeerCallKey +} + +var peerCallKeyObservers = struct { + sync.Mutex + registries map[*PacketRegistry]map[string]*peerCallKeyObserver +}{registries: make(map[*PacketRegistry]map[string]*peerCallKeyObserver)} + +func attachPeerCallKeyObserver(registry *PacketRegistry, instanceID string, client *whatsmeow.Client) { + if registry == nil || instanceID == "" || client == nil { + return + } + + peerCallKeyObservers.Lock() + instances := peerCallKeyObservers.registries[registry] + if instances == nil { + instances = make(map[string]*peerCallKeyObserver) + peerCallKeyObservers.registries[registry] = instances + } + current := instances[instanceID] + if current != nil && current.client == client { + peerCallKeyObservers.Unlock() + return + } + peerCallKeyObservers.Unlock() + if current != nil { + detachPeerCallKeyObserver(registry, instanceID) + } + + observer := &peerCallKeyObserver{client: client, keys: make(map[string]storedPeerCallKey)} + observer.handlerID = client.AddEventHandler(func(rawEvent interface{}) { + switch event := rawEvent.(type) { + case *events.CallAccept: + capturePeerCallKey(registry, instanceID, client, event) + case *events.CallReject: + removePeerCallKey(registry, instanceID, event.CallID) + case *events.CallTerminate: + removePeerCallKey(registry, instanceID, event.CallID) + case *events.Disconnected: + clearPeerCallKeys(registry, instanceID) + case *events.LoggedOut: + clearPeerCallKeys(registry, instanceID) + } + }) + + peerCallKeyObservers.Lock() + instances = peerCallKeyObservers.registries[registry] + if instances == nil { + instances = make(map[string]*peerCallKeyObserver) + peerCallKeyObservers.registries[registry] = instances + } + if previous := instances[instanceID]; previous != nil && previous.client != client { + peerCallKeyObservers.Unlock() + client.RemoveEventHandler(observer.handlerID) + wipePeerObserver(observer) + return + } + instances[instanceID] = observer + peerCallKeyObservers.Unlock() +} + +func capturePeerCallKey(registry *PacketRegistry, instanceID string, client *whatsmeow.Client, event *events.CallAccept) { + if registry == nil || client == nil || event == nil || event.CallID == "" || event.Data == nil || event.From.IsEmpty() { + return + } + ctx, cancel := context.WithTimeout(context.Background(), peerCallKeyDecryptTimeout) + defer cancel() + key, err := signaling.DecryptCallKeyInNode(ctx, wa.NewSocket(client), event.Data, event.From) + if err != nil || len(key) != 32 { + return + } + defer zeroBytes(key) + + peerCallKeyObservers.Lock() + observer := peerCallKeyObservers.registries[registry][instanceID] + if observer == nil || observer.client != client { + peerCallKeyObservers.Unlock() + return + } + if previous, exists := observer.keys[event.CallID]; exists { + zeroBytes(previous.key) + } + observer.keys[event.CallID] = storedPeerCallKey{ + key: append([]byte(nil), key...), + peer: event.From, + } + peerCallKeyObservers.Unlock() + + // A relay may have pre-connected on CallPreAccept. Rebuild any early packet + // session now so the first post-accept RTP frame can use the peer-provided key. + registry.Remove(instanceID, event.CallID) + if err = registry.Prepare(instanceID, event.CallID); err != nil { + slog.Debug("defer peer call-key SRTP refresh", "instance", instanceID, "call_id", event.CallID, "err", err) + return + } + slog.Info("WhatsApp peer call key applied", "instance", instanceID, "call_id", event.CallID, "peer", event.From.String()) +} + +func peerCallKey(registry *PacketRegistry, instanceID, callID string) ([]byte, types.JID, bool) { + peerCallKeyObservers.Lock() + defer peerCallKeyObservers.Unlock() + observer := peerCallKeyObservers.registries[registry][instanceID] + if observer == nil { + return nil, types.JID{}, false + } + stored, ok := observer.keys[callID] + if !ok || len(stored.key) != 32 || stored.peer.IsEmpty() { + return nil, types.JID{}, false + } + return append([]byte(nil), stored.key...), stored.peer, true +} + +func removePeerCallKey(registry *PacketRegistry, instanceID, callID string) { + if registry == nil || instanceID == "" || callID == "" { + return + } + peerCallKeyObservers.Lock() + observer := peerCallKeyObservers.registries[registry][instanceID] + if observer != nil { + if stored, ok := observer.keys[callID]; ok { + zeroBytes(stored.key) + delete(observer.keys, callID) + } + } + peerCallKeyObservers.Unlock() +} + +func clearPeerCallKeys(registry *PacketRegistry, instanceID string) { + if registry == nil || instanceID == "" { + return + } + peerCallKeyObservers.Lock() + observer := peerCallKeyObservers.registries[registry][instanceID] + if observer != nil { + for callID, stored := range observer.keys { + zeroBytes(stored.key) + delete(observer.keys, callID) + } + } + peerCallKeyObservers.Unlock() +} + +func detachPeerCallKeyObserver(registry *PacketRegistry, instanceID string) { + if registry == nil || instanceID == "" { + return + } + peerCallKeyObservers.Lock() + instances := peerCallKeyObservers.registries[registry] + observer := instances[instanceID] + delete(instances, instanceID) + if len(instances) == 0 { + delete(peerCallKeyObservers.registries, registry) + } + peerCallKeyObservers.Unlock() + if observer != nil { + if observer.client != nil && observer.handlerID != 0 { + observer.client.RemoveEventHandler(observer.handlerID) + } + wipePeerObserver(observer) + } +} + +func wipePeerObserver(observer *peerCallKeyObserver) { + if observer == nil { + return + } + for callID, stored := range observer.keys { + zeroBytes(stored.key) + delete(observer.keys, callID) + } + observer.client = nil + observer.handlerID = 0 +} + +func buildPacketSRTPCandidates( + registry *PacketRegistry, + instanceID, callID, selfDeviceJID string, + receiveJIDs []string, +) ([]packetSRTPCandidateKeying, error) { + if registry == nil || registry.source == nil { + return nil, ErrPacketSessionNotReady + } + + peerKey, acceptedPeer, hasPeerKey := peerCallKey(registry, instanceID, callID) + defer zeroBytes(peerKey) + candidateJIDs := append([]string(nil), receiveJIDs...) + if hasPeerKey { + candidateJIDs = uniqueDeviceJIDs(acceptedPeer.String(), ensureDeviceJIDString(acceptedPeer.String()), candidateJIDs...) + } + + keyings := make([]packetSRTPCandidateKeying, 0, len(candidateJIDs)+len(receiveJIDs)) + seenMaterial := make(map[string]struct{}) + appendCandidate := func(receiveJID string, send, receive core.SRTPKeyingMaterial) { + identity := fmt.Sprintf("%x:%x", receive.MasterKey, receive.MasterSalt) + if _, exists := seenMaterial[identity]; exists { + send.Wipe() + receive.Wipe() + return + } + seenMaterial[identity] = struct{}{} + keyings = append(keyings, packetSRTPCandidateKeying{receiveJID: receiveJID, send: send, receive: receive}) + } + + if hasPeerKey { + for _, receiveJID := range candidateJIDs { + send, originalReceive, err := registry.source.SRTPKeying(instanceID, callID, selfDeviceJID, receiveJID) + if err != nil { + wipePacketCandidateKeyings(keyings) + return nil, fmt.Errorf("derive send keying for peer call key: %w", err) + } + originalReceive.Wipe() + peerReceive, err := DerivePerJIDSRTPKey(peerKey, receiveJID) + if err != nil { + send.Wipe() + wipePacketCandidateKeyings(keyings) + return nil, fmt.Errorf("derive peer receive keying for %s: %w", receiveJID, err) + } + appendCandidate(receiveJID+" (peer-key)", send, peerReceive) + } + } + + for _, receiveJID := range receiveJIDs { + send, receive, err := registry.source.SRTPKeying(instanceID, callID, selfDeviceJID, receiveJID) + if err != nil { + wipePacketCandidateKeyings(keyings) + return nil, fmt.Errorf("derive SRTP candidate %s: %w", receiveJID, err) + } + appendCandidate(receiveJID, send, receive) + } + if len(keyings) == 0 { + return nil, fmt.Errorf("call %s produced no unique SRTP key candidates", callID) + } + return keyings, nil +} + +func wipePacketCandidateKeyings(keyings []packetSRTPCandidateKeying) { + for index := range keyings { + keyings[index].send.Wipe() + keyings[index].receive.Wipe() + } +} + +func equalPeerCallKeys(left, right []byte) bool { + return bytes.Equal(left, right) +} From c4ea2b20c18d045d995d65cff020206c876ffcf5 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:56:29 -0300 Subject: [PATCH 194/266] feat(call): integrate peer call-key candidates --- pkg/call/voip/media/packet_registry.go | 37 ++++++++++---------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/pkg/call/voip/media/packet_registry.go b/pkg/call/voip/media/packet_registry.go index 0272d279..63d6a36e 100644 --- a/pkg/call/voip/media/packet_registry.go +++ b/pkg/call/voip/media/packet_registry.go @@ -243,9 +243,11 @@ func (r *PacketRegistry) Attach(instanceID string, client *whatsmeow.Client) { delete(r.sessions, instanceID) r.mu.Unlock() closePacketSessions(sessions) + attachPeerCallKeyObserver(r, instanceID, client) return } r.mu.Unlock() + attachPeerCallKeyObserver(r, instanceID, client) } func (r *PacketRegistry) Prepare(instanceID, callID string) error { @@ -336,28 +338,11 @@ func (r *PacketRegistry) PrepareWithDeviceCandidates(instanceID, callID, selfDev return fmt.Errorf("call %s has no SRTP receive JID candidates", callID) } - keyings := make([]packetSRTPCandidateKeying, 0, len(receiveJIDs)) - for _, receiveJID := range receiveJIDs { - sendKeying, receiveKeying, err := r.source.SRTPKeying(instanceID, callID, selfDeviceJID, receiveJID) - if err != nil { - for index := range keyings { - keyings[index].send.Wipe() - keyings[index].receive.Wipe() - } - return fmt.Errorf("derive SRTP candidate %s: %w", receiveJID, err) - } - keyings = append(keyings, packetSRTPCandidateKeying{ - receiveJID: receiveJID, - send: sendKeying, - receive: receiveKeying, - }) - } - defer func() { - for index := range keyings { - keyings[index].send.Wipe() - keyings[index].receive.Wipe() - } - }() + keyings, err := buildPacketSRTPCandidates(r, instanceID, callID, selfDeviceJID, receiveJIDs) + if err != nil { + return err + } + defer wipePacketCandidateKeyings(keyings) candidate, err := newPacketSessionCandidates(keyings, selfSSRC, peerSSRC) if err != nil { @@ -377,11 +362,15 @@ func (r *PacketRegistry) PrepareWithDeviceCandidates(instanceID, callID, selfDev previous.close() } + labels := make([]string, 0, len(keyings)) + for _, keying := range keyings { + labels = append(labels, keying.receiveJID) + } slog.Info("WhatsApp SRTP receive candidates prepared", "instance", instanceID, "call_id", callID, "self_jid", selfDeviceJID, - "receive_jids", receiveJIDs, + "receive_jids", labels, ) return nil } @@ -477,12 +466,14 @@ func (r *PacketRegistry) Remove(instanceID, callID string) { if session != nil { session.close() } + removePeerCallKey(r, instanceID, callID) } func (r *PacketRegistry) Close(instanceID string) { if r == nil { return } + detachPeerCallKeyObserver(r, instanceID) r.mu.Lock() delete(r.clients, instanceID) sessions := r.sessions[instanceID] From 4b99f75537fb586a939fb832d9a3afce41c1bff5 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:57:27 -0300 Subject: [PATCH 195/266] fix(call): preserve peer key during SRTP refresh --- pkg/call/voip/media/peer_call_key.go | 53 +++++++++++++++++++++------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/pkg/call/voip/media/peer_call_key.go b/pkg/call/voip/media/peer_call_key.go index 9c994af6..c89efff7 100644 --- a/pkg/call/voip/media/peer_call_key.go +++ b/pkg/call/voip/media/peer_call_key.go @@ -1,7 +1,6 @@ package media import ( - "bytes" "context" "fmt" "log/slog" @@ -114,9 +113,9 @@ func capturePeerCallKey(registry *PacketRegistry, instanceID string, client *wha } peerCallKeyObservers.Unlock() - // A relay may have pre-connected on CallPreAccept. Rebuild any early packet - // session now so the first post-accept RTP frame can use the peer-provided key. - registry.Remove(instanceID, event.CallID) + // A relay may have pre-connected on CallPreAccept. Rebuild only the packet + // session, preserving the peer key that was just stored. + removePacketSessionOnly(registry, instanceID, event.CallID) if err = registry.Prepare(instanceID, event.CallID); err != nil { slog.Debug("defer peer call-key SRTP refresh", "instance", instanceID, "call_id", event.CallID, "err", err) return @@ -124,6 +123,23 @@ func capturePeerCallKey(registry *PacketRegistry, instanceID string, client *wha slog.Info("WhatsApp peer call key applied", "instance", instanceID, "call_id", event.CallID, "peer", event.From.String()) } +func removePacketSessionOnly(registry *PacketRegistry, instanceID, callID string) { + if registry == nil || instanceID == "" || callID == "" { + return + } + registry.mu.Lock() + calls := registry.sessions[instanceID] + session := calls[callID] + delete(calls, callID) + if len(calls) == 0 { + delete(registry.sessions, instanceID) + } + registry.mu.Unlock() + if session != nil { + session.close() + } +} + func peerCallKey(registry *PacketRegistry, instanceID, callID string) ([]byte, types.JID, bool) { peerCallKeyObservers.Lock() defer peerCallKeyObservers.Unlock() @@ -211,12 +227,13 @@ func buildPacketSRTPCandidates( peerKey, acceptedPeer, hasPeerKey := peerCallKey(registry, instanceID, callID) defer zeroBytes(peerKey) - candidateJIDs := append([]string(nil), receiveJIDs...) + peerKeyJIDs := []string(nil) if hasPeerKey { - candidateJIDs = uniqueDeviceJIDs(acceptedPeer.String(), ensureDeviceJIDString(acceptedPeer.String()), candidateJIDs...) + peerKeyJIDs = uniqueJIDStrings(acceptedPeer.String(), ensureDeviceJIDString(acceptedPeer.String())) + peerKeyJIDs = uniqueJIDStrings(append(peerKeyJIDs, receiveJIDs...)...) } - keyings := make([]packetSRTPCandidateKeying, 0, len(candidateJIDs)+len(receiveJIDs)) + keyings := make([]packetSRTPCandidateKeying, 0, len(peerKeyJIDs)+len(receiveJIDs)) seenMaterial := make(map[string]struct{}) appendCandidate := func(receiveJID string, send, receive core.SRTPKeyingMaterial) { identity := fmt.Sprintf("%x:%x", receive.MasterKey, receive.MasterSalt) @@ -230,7 +247,7 @@ func buildPacketSRTPCandidates( } if hasPeerKey { - for _, receiveJID := range candidateJIDs { + for _, receiveJID := range peerKeyJIDs { send, originalReceive, err := registry.source.SRTPKeying(instanceID, callID, selfDeviceJID, receiveJID) if err != nil { wipePacketCandidateKeyings(keyings) @@ -261,13 +278,25 @@ func buildPacketSRTPCandidates( return keyings, nil } +func uniqueJIDStrings(values ...string) []string { + seen := make(map[string]struct{}, len(values)) + output := make([]string, 0, len(values)) + for _, value := range values { + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + output = append(output, value) + } + return output +} + func wipePacketCandidateKeyings(keyings []packetSRTPCandidateKeying) { for index := range keyings { keyings[index].send.Wipe() keyings[index].receive.Wipe() } } - -func equalPeerCallKeys(left, right []byte) bool { - return bytes.Equal(left, right) -} From 7ef8a1610dd2c4d93c538325682ad2093ac100f3 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:58:07 -0300 Subject: [PATCH 196/266] test(call): cover peer-provided call key fallback --- .../media/packet_registry_candidates_test.go | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/pkg/call/voip/media/packet_registry_candidates_test.go b/pkg/call/voip/media/packet_registry_candidates_test.go index b4c3893a..03a6123e 100644 --- a/pkg/call/voip/media/packet_registry_candidates_test.go +++ b/pkg/call/voip/media/packet_registry_candidates_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "go.mau.fi/whatsmeow/types" ) func TestPacketRegistryFallsBackToAuthenticatedReceiveJID(t *testing.T) { @@ -78,3 +79,84 @@ func TestPacketRegistryFallsBackToAuthenticatedReceiveJID(t *testing.T) { t.Fatalf("unexpected selected receive JID: observed=%v selected=%s", observed, selected) } } + +func TestPacketRegistryAcceptsPeerProvidedCallKey(t *testing.T) { + originalCallKey := bytes.Repeat([]byte{0x41}, 32) + remoteCallKey := bytes.Repeat([]byte{0x52}, 32) + registry := NewPacketRegistry(&fakePacketSource{callKey: originalCallKey}) + const ( + instanceID = "instance-peer-key" + callID = "call-peer-key" + selfDevice = "self:3@lid" + peerDevice = "peer:0@lid" + selfSSRC = uint32(0x30303030) + peerSSRC = uint32(0x40404040) + ) + acceptedPeer := types.NewJID("peer", types.HiddenUserServer) + + peerCallKeyObservers.Lock() + peerCallKeyObservers.registries[registry] = map[string]*peerCallKeyObserver{ + instanceID: { + keys: map[string]storedPeerCallKey{ + callID: {key: append([]byte(nil), remoteCallKey...), peer: acceptedPeer}, + }, + }, + } + peerCallKeyObservers.Unlock() + defer registry.Close(instanceID) + + if err := registry.PrepareWithDeviceCandidates( + instanceID, + callID, + selfDevice, + []string{peerDevice}, + selfSSRC, + peerSSRC, + ); err != nil { + t.Fatal(err) + } + + peerSend, err := DerivePerJIDSRTPKey(remoteCallKey, acceptedPeer.String()) + if err != nil { + t.Fatal(err) + } + defer peerSend.Wipe() + peerReceive, err := DerivePerJIDSRTPKey(originalCallKey, selfDevice) + if err != nil { + t.Fatal(err) + } + defer peerReceive.Wipe() + peerSession, err := NewSRTPSession(peerSend, peerReceive, core.SRTPRecvAuthTagLen, core.SRTPSendAuthTagLen) + if err != nil { + t.Fatal(err) + } + defer peerSession.Close() + + peerRTP, err := NewWhatsAppOpusRTPSession(peerSSRC) + if err != nil { + t.Fatal(err) + } + frame, err := peerSession.Protect(peerRTP.CreatePacket([]byte("peer-key audio"), true)) + if err != nil { + t.Fatal(err) + } + packet, err := registry.Unprotect(instanceID, callID, frame) + if err != nil { + t.Fatalf("expected peer-provided call key to authenticate packet: %v", err) + } + defer packet.Wipe() + if string(packet.Payload) != "peer-key audio" { + t.Fatalf("unexpected payload: %q", packet.Payload) + } + + session, err := registry.packetSession(instanceID, callID, false) + if err != nil { + t.Fatal(err) + } + session.mu.RLock() + selected := session.srtpCandidates[session.activeCandidate].receiveJID + session.mu.RUnlock() + if selected != acceptedPeer.String()+" (peer-key)" { + t.Fatalf("unexpected peer-key candidate selected: %s", selected) + } +} From 28600e8dc6f526c248301dc8602798d2d0a74739 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:12:41 -0300 Subject: [PATCH 197/266] fix(call): harden peer call key handling --- pkg/call/voip/media/peer_call_key.go | 158 +++++++++++++++++++-------- 1 file changed, 110 insertions(+), 48 deletions(-) diff --git a/pkg/call/voip/media/peer_call_key.go b/pkg/call/voip/media/peer_call_key.go index c89efff7..b460f534 100644 --- a/pkg/call/voip/media/peer_call_key.go +++ b/pkg/call/voip/media/peer_call_key.go @@ -1,7 +1,10 @@ package media import ( + "bytes" "context" + "crypto/sha256" + "errors" "fmt" "log/slog" "sync" @@ -11,6 +14,7 @@ import ( "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" "go.mau.fi/whatsmeow" + waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" ) @@ -18,8 +22,8 @@ import ( const peerCallKeyDecryptTimeout = 5 * time.Second type storedPeerCallKey struct { - key []byte - peer types.JID + key []byte + peers []types.JID } type peerCallKeyObserver struct { @@ -87,16 +91,28 @@ func attachPeerCallKeyObserver(registry *PacketRegistry, instanceID string, clie } func capturePeerCallKey(registry *PacketRegistry, instanceID string, client *whatsmeow.Client, event *events.CallAccept) { - if registry == nil || client == nil || event == nil || event.CallID == "" || event.Data == nil || event.From.IsEmpty() { + if registry == nil || client == nil || event == nil || event.CallID == "" || event.Data == nil { return } + peerCandidates := callKeyPeerCandidates(event) + if len(peerCandidates) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), peerCallKeyDecryptTimeout) defer cancel() - key, err := signaling.DecryptCallKeyInNode(ctx, wa.NewSocket(client), event.Data, event.From) + key, decryptedPeer, err := decryptPeerCallKey(ctx, wa.NewSocket(client), event.Data, peerCandidates) if err != nil || len(key) != 32 { + slog.Debug("WhatsApp peer call key not available", + "instance", instanceID, + "call_id", event.CallID, + "candidates", len(peerCandidates), + "err", err, + ) return } defer zeroBytes(key) + peerCandidates = uniqueCallKeyPeers(append([]types.JID{decryptedPeer}, peerCandidates...)...) peerCallKeyObservers.Lock() observer := peerCallKeyObservers.registries[registry][instanceID] @@ -105,53 +121,84 @@ func capturePeerCallKey(registry *PacketRegistry, instanceID string, client *wha return } if previous, exists := observer.keys[event.CallID]; exists { + if equalPeerCallKeys(previous.key, key) && equalCallKeyPeers(previous.peers, peerCandidates) { + peerCallKeyObservers.Unlock() + return + } zeroBytes(previous.key) } observer.keys[event.CallID] = storedPeerCallKey{ - key: append([]byte(nil), key...), - peer: event.From, + key: append([]byte(nil), key...), + peers: append([]types.JID(nil), peerCandidates...), } peerCallKeyObservers.Unlock() - // A relay may have pre-connected on CallPreAccept. Rebuild only the packet - // session, preserving the peer key that was just stored. - removePacketSessionOnly(registry, instanceID, event.CallID) + // A relay may have pre-connected on CallPreAccept. Rebuild any early packet + // session now so the first post-accept RTP frame can use the peer-provided key. + registry.dropSession(instanceID, event.CallID) if err = registry.Prepare(instanceID, event.CallID); err != nil { slog.Debug("defer peer call-key SRTP refresh", "instance", instanceID, "call_id", event.CallID, "err", err) return } - slog.Info("WhatsApp peer call key applied", "instance", instanceID, "call_id", event.CallID, "peer", event.From.String()) + slog.Info("WhatsApp peer call key applied", + "instance", instanceID, + "call_id", event.CallID, + "peer", decryptedPeer.String(), + "candidates", len(peerCandidates), + ) } -func removePacketSessionOnly(registry *PacketRegistry, instanceID, callID string) { - if registry == nil || instanceID == "" || callID == "" { - return +func callKeyPeerCandidates(event *events.CallAccept) []types.JID { + if event == nil { + return nil } - registry.mu.Lock() - calls := registry.sessions[instanceID] - session := calls[callID] - delete(calls, callID) - if len(calls) == 0 { - delete(registry.sessions, instanceID) + return uniqueCallKeyPeers(event.From, event.CallCreator, event.CallCreatorAlt) +} + +func uniqueCallKeyPeers(values ...types.JID) []types.JID { + seen := make(map[string]struct{}, len(values)) + output := make([]types.JID, 0, len(values)) + for _, value := range values { + if value.IsEmpty() { + continue + } + identity := value.String() + if _, exists := seen[identity]; exists { + continue + } + seen[identity] = struct{}{} + output = append(output, value) } - registry.mu.Unlock() - if session != nil { - session.close() + return output +} + +func decryptPeerCallKey(ctx context.Context, socket core.VoipSocket, node *waBinary.Node, peers []types.JID) ([]byte, types.JID, error) { + if socket == nil || node == nil || len(peers) == 0 { + return nil, types.JID{}, fmt.Errorf("peer call-key inputs are incomplete") + } + attemptErrors := make([]error, 0, len(peers)) + for _, peer := range peers { + key, err := signaling.DecryptCallKeyInNode(ctx, socket, node, peer) + if err == nil { + return key, peer, nil + } + attemptErrors = append(attemptErrors, fmt.Errorf("peer=%s: %w", peer.String(), err)) } + return nil, types.JID{}, fmt.Errorf("decrypt peer call key with %d candidates: %w", len(peers), errors.Join(attemptErrors...)) } -func peerCallKey(registry *PacketRegistry, instanceID, callID string) ([]byte, types.JID, bool) { +func peerCallKey(registry *PacketRegistry, instanceID, callID string) ([]byte, []types.JID, bool) { peerCallKeyObservers.Lock() defer peerCallKeyObservers.Unlock() observer := peerCallKeyObservers.registries[registry][instanceID] if observer == nil { - return nil, types.JID{}, false + return nil, nil, false } stored, ok := observer.keys[callID] - if !ok || len(stored.key) != 32 || stored.peer.IsEmpty() { - return nil, types.JID{}, false + if !ok || len(stored.key) != 32 || len(stored.peers) == 0 { + return nil, nil, false } - return append([]byte(nil), stored.key...), stored.peer, true + return append([]byte(nil), stored.key...), append([]types.JID(nil), stored.peers...), true } func removePeerCallKey(registry *PacketRegistry, instanceID, callID string) { @@ -225,18 +272,22 @@ func buildPacketSRTPCandidates( return nil, ErrPacketSessionNotReady } - peerKey, acceptedPeer, hasPeerKey := peerCallKey(registry, instanceID, callID) + peerKey, acceptedPeers, hasPeerKey := peerCallKey(registry, instanceID, callID) defer zeroBytes(peerKey) - peerKeyJIDs := []string(nil) + candidateJIDs := append([]string(nil), receiveJIDs...) if hasPeerKey { - peerKeyJIDs = uniqueJIDStrings(acceptedPeer.String(), ensureDeviceJIDString(acceptedPeer.String())) - peerKeyJIDs = uniqueJIDStrings(append(peerKeyJIDs, receiveJIDs...)...) + peerJIDs := make([]string, 0, len(acceptedPeers)*2+len(receiveJIDs)) + for _, peer := range acceptedPeers { + peerJIDs = append(peerJIDs, peer.String(), ensureDeviceJIDString(peer.String())) + } + peerJIDs = append(peerJIDs, candidateJIDs...) + candidateJIDs = uniqueDeviceJIDs(peerJIDs...) } - keyings := make([]packetSRTPCandidateKeying, 0, len(peerKeyJIDs)+len(receiveJIDs)) - seenMaterial := make(map[string]struct{}) + keyings := make([]packetSRTPCandidateKeying, 0, len(candidateJIDs)+len(receiveJIDs)) + seenMaterial := make(map[[sha256.Size]byte]struct{}) appendCandidate := func(receiveJID string, send, receive core.SRTPKeyingMaterial) { - identity := fmt.Sprintf("%x:%x", receive.MasterKey, receive.MasterSalt) + identity := packetKeyingFingerprint(receive) if _, exists := seenMaterial[identity]; exists { send.Wipe() receive.Wipe() @@ -247,7 +298,7 @@ func buildPacketSRTPCandidates( } if hasPeerKey { - for _, receiveJID := range peerKeyJIDs { + for _, receiveJID := range candidateJIDs { send, originalReceive, err := registry.source.SRTPKeying(instanceID, callID, selfDeviceJID, receiveJID) if err != nil { wipePacketCandidateKeyings(keyings) @@ -278,19 +329,14 @@ func buildPacketSRTPCandidates( return keyings, nil } -func uniqueJIDStrings(values ...string) []string { - seen := make(map[string]struct{}, len(values)) - output := make([]string, 0, len(values)) - for _, value := range values { - if value == "" { - continue - } - if _, exists := seen[value]; exists { - continue - } - seen[value] = struct{}{} - output = append(output, value) - } +func packetKeyingFingerprint(keying core.SRTPKeyingMaterial) [sha256.Size]byte { + hash := sha256.New() + _, _ = hash.Write([]byte{byte(len(keying.MasterKey))}) + _, _ = hash.Write(keying.MasterKey) + _, _ = hash.Write([]byte{byte(len(keying.MasterSalt))}) + _, _ = hash.Write(keying.MasterSalt) + var output [sha256.Size]byte + copy(output[:], hash.Sum(nil)) return output } @@ -300,3 +346,19 @@ func wipePacketCandidateKeyings(keyings []packetSRTPCandidateKeying) { keyings[index].receive.Wipe() } } + +func equalPeerCallKeys(left, right []byte) bool { + return bytes.Equal(left, right) +} + +func equalCallKeyPeers(left, right []types.JID) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index].String() != right[index].String() { + return false + } + } + return true +} From dd9facbf64b908c5b1acb0c5e7334f78653d0470 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:13:01 -0300 Subject: [PATCH 198/266] test(call): cover peer call key candidate safety --- pkg/call/voip/media/peer_call_key_test.go | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 pkg/call/voip/media/peer_call_key_test.go diff --git a/pkg/call/voip/media/peer_call_key_test.go b/pkg/call/voip/media/peer_call_key_test.go new file mode 100644 index 00000000..0fc6f276 --- /dev/null +++ b/pkg/call/voip/media/peer_call_key_test.go @@ -0,0 +1,66 @@ +package media + +import ( + "bytes" + "testing" + + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +func TestCallKeyPeerCandidatesPreservesDeviceOrderAndDeduplicates(t *testing.T) { + from := types.NewADJID("5511999999999", 7, 3) + creator := types.NewJID("5511999999999", types.HiddenUserServer) + alt := types.NewJID("5511999999999", types.DefaultUserServer) + event := &events.CallAccept{} + event.From = from + event.CallCreator = creator + event.CallCreatorAlt = alt + + got := callKeyPeerCandidates(event) + if len(got) != 3 { + t.Fatalf("unexpected candidate count: %d", len(got)) + } + if got[0].String() != from.String() || got[1].String() != creator.String() || got[2].String() != alt.String() { + t.Fatalf("unexpected candidate order: %#v", got) + } + + duplicated := uniqueCallKeyPeers(from, from, creator, creator) + if len(duplicated) != 2 { + t.Fatalf("duplicate peers were not removed: %#v", duplicated) + } +} + +func TestPacketKeyingFingerprintIsStableAndSeparatesMaterials(t *testing.T) { + first := core.SRTPKeyingMaterial{ + MasterKey: bytes.Repeat([]byte{0x11}, 16), + MasterSalt: bytes.Repeat([]byte{0x22}, 14), + } + same := core.SRTPKeyingMaterial{ + MasterKey: append([]byte(nil), first.MasterKey...), + MasterSalt: append([]byte(nil), first.MasterSalt...), + } + different := core.SRTPKeyingMaterial{ + MasterKey: bytes.Repeat([]byte{0x11}, 16), + MasterSalt: bytes.Repeat([]byte{0x23}, 14), + } + + if packetKeyingFingerprint(first) != packetKeyingFingerprint(same) { + t.Fatal("equal keying material produced different fingerprints") + } + if packetKeyingFingerprint(first) == packetKeyingFingerprint(different) { + t.Fatal("different keying material produced the same fingerprint") + } +} + +func TestEqualCallKeyPeersIsOrderSensitive(t *testing.T) { + first := types.NewJID("first", types.HiddenUserServer) + second := types.NewJID("second", types.HiddenUserServer) + if !equalCallKeyPeers([]types.JID{first, second}, []types.JID{first, second}) { + t.Fatal("equal peer lists were not recognized") + } + if equalCallKeyPeers([]types.JID{first, second}, []types.JID{second, first}) { + t.Fatal("different peer priority order was treated as equal") + } +} From c76d242bf3f54f5a5aaef10dbb407b39fe760299 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:15:48 -0300 Subject: [PATCH 199/266] fix(call): preserve peer key while refreshing SRTP --- .../voip/media/packet_registry_refresh.go | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 pkg/call/voip/media/packet_registry_refresh.go diff --git a/pkg/call/voip/media/packet_registry_refresh.go b/pkg/call/voip/media/packet_registry_refresh.go new file mode 100644 index 00000000..8d2e518d --- /dev/null +++ b/pkg/call/voip/media/packet_registry_refresh.go @@ -0,0 +1,26 @@ +package media + +// dropSession removes only the RTP/SRTP packet state for a call. Unlike +// Remove, it deliberately preserves a peer-provided call key so the session can +// be rebuilt immediately after a CallAccept carrying a new payload. +func (r *PacketRegistry) dropSession(instanceID, callID string) { + if r == nil || instanceID == "" || callID == "" { + return + } + + r.mu.Lock() + calls := r.sessions[instanceID] + var session *packetSession + if calls != nil { + session = calls[callID] + delete(calls, callID) + if len(calls) == 0 { + delete(r.sessions, instanceID) + } + } + r.mu.Unlock() + + if session != nil { + session.close() + } +} From 1ef5eb087cd823ba53bd72d0a75cabcedd6d80c0 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:16:44 -0300 Subject: [PATCH 200/266] test(call): preserve peer key during packet refresh --- .../media/packet_registry_refresh_test.go | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 pkg/call/voip/media/packet_registry_refresh_test.go diff --git a/pkg/call/voip/media/packet_registry_refresh_test.go b/pkg/call/voip/media/packet_registry_refresh_test.go new file mode 100644 index 00000000..5737c98c --- /dev/null +++ b/pkg/call/voip/media/packet_registry_refresh_test.go @@ -0,0 +1,47 @@ +package media + +import ( + "bytes" + "errors" + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestDropSessionPreservesPeerCallKeyUntilFinalRemove(t *testing.T) { + registry := NewPacketRegistry(&fakePacketSource{callKey: bytes.Repeat([]byte{0x41}, 32)}) + const ( + instanceID = "refresh-instance" + callID = "refresh-call" + ) + if err := registry.PrepareWithDevices(instanceID, callID, "self:1@lid", "peer:2@lid", 101, 202); err != nil { + t.Fatal(err) + } + + peer := types.NewJID("peer", types.HiddenUserServer) + peerCallKeyObservers.Lock() + peerCallKeyObservers.registries[registry] = map[string]*peerCallKeyObserver{ + instanceID: { + keys: map[string]storedPeerCallKey{ + callID: {key: bytes.Repeat([]byte{0x52}, 32), peers: []types.JID{peer}}, + }, + }, + } + peerCallKeyObservers.Unlock() + defer detachPeerCallKeyObserver(registry, instanceID) + + registry.dropSession(instanceID, callID) + if _, err := registry.packetSession(instanceID, callID, false); !errors.Is(err, ErrPacketSessionNotReady) { + t.Fatalf("packet session remained after refresh drop: %v", err) + } + key, peers, ok := peerCallKey(registry, instanceID, callID) + if !ok || len(key) != 32 || len(peers) != 1 || peers[0].String() != peer.String() { + t.Fatalf("peer key was not preserved: ok=%v key=%d peers=%#v", ok, len(key), peers) + } + zeroBytes(key) + + registry.Remove(instanceID, callID) + if _, _, ok = peerCallKey(registry, instanceID, callID); ok { + t.Fatal("final Remove did not wipe the peer call key") + } +} From cdff518fc0f5748ea62e2cdcdadc9113fdd6a6fa Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:18:15 -0300 Subject: [PATCH 201/266] test(call): adapt peer key candidate list --- pkg/call/voip/media/packet_registry_candidates_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/call/voip/media/packet_registry_candidates_test.go b/pkg/call/voip/media/packet_registry_candidates_test.go index 03a6123e..56b27ae6 100644 --- a/pkg/call/voip/media/packet_registry_candidates_test.go +++ b/pkg/call/voip/media/packet_registry_candidates_test.go @@ -98,7 +98,7 @@ func TestPacketRegistryAcceptsPeerProvidedCallKey(t *testing.T) { peerCallKeyObservers.registries[registry] = map[string]*peerCallKeyObserver{ instanceID: { keys: map[string]storedPeerCallKey{ - callID: {key: append([]byte(nil), remoteCallKey...), peer: acceptedPeer}, + callID: {key: append([]byte(nil), remoteCallKey...), peers: []types.JID{acceptedPeer}}, }, }, } From 35ce52b56bbc1678294e3ba20c039e36cf538ddf Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:20:35 -0300 Subject: [PATCH 202/266] test(call): use distinct call key peer identities --- pkg/call/voip/media/peer_call_key_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/call/voip/media/peer_call_key_test.go b/pkg/call/voip/media/peer_call_key_test.go index 0fc6f276..1b363b13 100644 --- a/pkg/call/voip/media/peer_call_key_test.go +++ b/pkg/call/voip/media/peer_call_key_test.go @@ -11,8 +11,8 @@ import ( func TestCallKeyPeerCandidatesPreservesDeviceOrderAndDeduplicates(t *testing.T) { from := types.NewADJID("5511999999999", 7, 3) - creator := types.NewJID("5511999999999", types.HiddenUserServer) - alt := types.NewJID("5511999999999", types.DefaultUserServer) + creator := types.NewJID("5511888888888", types.HiddenUserServer) + alt := types.NewJID("5511777777777", types.DefaultUserServer) event := &events.CallAccept{} event.From = from event.CallCreator = creator From 65b2d34b484f78998fd6a5fba9110175929be3e9 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:21:14 -0300 Subject: [PATCH 203/266] test(call): match normalized peer-key JID --- pkg/call/voip/media/packet_registry_candidates_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/call/voip/media/packet_registry_candidates_test.go b/pkg/call/voip/media/packet_registry_candidates_test.go index 56b27ae6..cfc18d2b 100644 --- a/pkg/call/voip/media/packet_registry_candidates_test.go +++ b/pkg/call/voip/media/packet_registry_candidates_test.go @@ -116,7 +116,7 @@ func TestPacketRegistryAcceptsPeerProvidedCallKey(t *testing.T) { t.Fatal(err) } - peerSend, err := DerivePerJIDSRTPKey(remoteCallKey, acceptedPeer.String()) + peerSend, err := DerivePerJIDSRTPKey(remoteCallKey, peerDevice) if err != nil { t.Fatal(err) } @@ -156,7 +156,7 @@ func TestPacketRegistryAcceptsPeerProvidedCallKey(t *testing.T) { session.mu.RLock() selected := session.srtpCandidates[session.activeCandidate].receiveJID session.mu.RUnlock() - if selected != acceptedPeer.String()+" (peer-key)" { + if selected != peerDevice+" (peer-key)" { t.Fatalf("unexpected peer-key candidate selected: %s", selected) } } From 48ef1854120b60ae0b7e64849950847b71c60587 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:27 -0300 Subject: [PATCH 204/266] test(call): use valid peer candidate fixtures --- pkg/call/voip/media/peer_call_key_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/call/voip/media/peer_call_key_test.go b/pkg/call/voip/media/peer_call_key_test.go index 1b363b13..150eaba8 100644 --- a/pkg/call/voip/media/peer_call_key_test.go +++ b/pkg/call/voip/media/peer_call_key_test.go @@ -9,8 +9,8 @@ import ( "go.mau.fi/whatsmeow/types/events" ) -func TestCallKeyPeerCandidatesPreservesDeviceOrderAndDeduplicates(t *testing.T) { - from := types.NewADJID("5511999999999", 7, 3) +func TestCallKeyPeerCandidatesPreservesOrderAndDeduplicates(t *testing.T) { + from := types.NewJID("5511999999999", types.HiddenUserServer) creator := types.NewJID("5511888888888", types.HiddenUserServer) alt := types.NewJID("5511777777777", types.DefaultUserServer) event := &events.CallAccept{} @@ -20,7 +20,7 @@ func TestCallKeyPeerCandidatesPreservesDeviceOrderAndDeduplicates(t *testing.T) got := callKeyPeerCandidates(event) if len(got) != 3 { - t.Fatalf("unexpected candidate count: %d", len(got)) + t.Fatalf("unexpected candidate count: %d (%#v)", len(got), got) } if got[0].String() != from.String() || got[1].String() != creator.String() || got[2].String() != alt.String() { t.Fatalf("unexpected candidate order: %#v", got) From 3cf82d459a02f14ae9bd14278a68202041bb8546 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:24:23 -0300 Subject: [PATCH 205/266] fix(call): serialize peer key observer replacement --- pkg/call/voip/media/peer_call_key.go | 35 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/pkg/call/voip/media/peer_call_key.go b/pkg/call/voip/media/peer_call_key.go index b460f534..033843c3 100644 --- a/pkg/call/voip/media/peer_call_key.go +++ b/pkg/call/voip/media/peer_call_key.go @@ -1,9 +1,9 @@ package media import ( - "bytes" "context" "crypto/sha256" + "crypto/subtle" "errors" "fmt" "log/slog" @@ -42,24 +42,33 @@ func attachPeerCallKeyObserver(registry *PacketRegistry, instanceID string, clie return } + observer := &peerCallKeyObserver{client: client, keys: make(map[string]storedPeerCallKey)} + + // Reserve the instance slot before registering the callback. This serializes + // rapid reconnects: a later client replaces the earlier reservation, while + // duplicate attaches of the same client become no-ops. peerCallKeyObservers.Lock() instances := peerCallKeyObservers.registries[registry] if instances == nil { instances = make(map[string]*peerCallKeyObserver) peerCallKeyObservers.registries[registry] = instances } - current := instances[instanceID] - if current != nil && current.client == client { + previous := instances[instanceID] + if previous != nil && previous.client == client { peerCallKeyObservers.Unlock() return } + instances[instanceID] = observer peerCallKeyObservers.Unlock() - if current != nil { - detachPeerCallKeyObserver(registry, instanceID) + + if previous != nil { + if previous.client != nil && previous.handlerID != 0 { + previous.client.RemoveEventHandler(previous.handlerID) + } + wipePeerObserver(previous) } - observer := &peerCallKeyObserver{client: client, keys: make(map[string]storedPeerCallKey)} - observer.handlerID = client.AddEventHandler(func(rawEvent interface{}) { + handlerID := client.AddEventHandler(func(rawEvent interface{}) { switch event := rawEvent.(type) { case *events.CallAccept: capturePeerCallKey(registry, instanceID, client, event) @@ -76,17 +85,13 @@ func attachPeerCallKeyObserver(registry *PacketRegistry, instanceID string, clie peerCallKeyObservers.Lock() instances = peerCallKeyObservers.registries[registry] - if instances == nil { - instances = make(map[string]*peerCallKeyObserver) - peerCallKeyObservers.registries[registry] = instances - } - if previous := instances[instanceID]; previous != nil && previous.client != client { + if instances == nil || instances[instanceID] != observer { peerCallKeyObservers.Unlock() - client.RemoveEventHandler(observer.handlerID) + client.RemoveEventHandler(handlerID) wipePeerObserver(observer) return } - instances[instanceID] = observer + observer.handlerID = handlerID peerCallKeyObservers.Unlock() } @@ -348,7 +353,7 @@ func wipePacketCandidateKeyings(keyings []packetSRTPCandidateKeying) { } func equalPeerCallKeys(left, right []byte) bool { - return bytes.Equal(left, right) + return len(left) == len(right) && subtle.ConstantTimeCompare(left, right) == 1 } func equalCallKeyPeers(left, right []types.JID) bool { From b00a93a6602e40297bf242cbb051cc7d20f24546 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:09:22 -0300 Subject: [PATCH 206/266] fix(call): make post-accept signaling idempotent --- pkg/call/voip/media/post_accept.go | 152 +++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 9 deletions(-) diff --git a/pkg/call/voip/media/post_accept.go b/pkg/call/voip/media/post_accept.go index fd88e014..f8f517c8 100644 --- a/pkg/call/voip/media/post_accept.go +++ b/pkg/call/voip/media/post_accept.go @@ -3,19 +3,140 @@ package media import ( "context" "fmt" + "sync" "time" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" + "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" ) -const postAcceptSignalingTimeout = 5 * time.Second +const ( + postAcceptSignalingTimeout = 5 * time.Second + postAcceptProgressTTL = 10 * time.Minute +) + +type postAcceptProgressKey struct { + session *relaySession + callID string +} + +type postAcceptProgress struct { + running bool + transportSent bool + muteSent bool + expiresAt time.Time +} + +type postAcceptProgressTracker struct { + sync.Mutex + calls map[postAcceptProgressKey]*postAcceptProgress +} + +var outgoingPostAcceptProgress = postAcceptProgressTracker{ + calls: make(map[postAcceptProgressKey]*postAcceptProgress), +} + +var resolvePostAcceptPeer = func(ctx context.Context, client *whatsmeow.Client, peer types.JID) types.JID { + return wa.NewSocket(client).ResolveLIDForPN(ctx, peer) +} + +var sendPostAcceptTransport = func( + ctx context.Context, + client *whatsmeow.Client, + peer, creator types.JID, + callID string, +) error { + return wa.NewSocket(client).SendNode(ctx, signaling.BuildPostAcceptTransportStanza(peer, creator, callID)) +} + +var sendPostAcceptMute = func( + ctx context.Context, + client *whatsmeow.Client, + peer, creator types.JID, + callID string, +) error { + return wa.NewSocket(client).SendNode(ctx, signaling.BuildMuteV2Stanza(peer, creator, callID, 0)) +} + +func (t *postAcceptProgressTracker) begin(session *relaySession, callID string) (postAcceptProgress, bool) { + if t == nil || session == nil || callID == "" { + return postAcceptProgress{}, false + } + + now := time.Now() + key := postAcceptProgressKey{session: session, callID: callID} + t.Lock() + defer t.Unlock() + if t.calls == nil { + t.calls = make(map[postAcceptProgressKey]*postAcceptProgress) + } + for existingKey, progress := range t.calls { + if progress != nil && !progress.running && !progress.expiresAt.IsZero() && !progress.expiresAt.After(now) { + delete(t.calls, existingKey) + } + } + + progress := t.calls[key] + if progress == nil { + progress = &postAcceptProgress{} + t.calls[key] = progress + } + if progress.running || progress.muteSent { + return postAcceptProgress{}, false + } + progress.running = true + progress.expiresAt = time.Time{} + return *progress, true +} + +func (t *postAcceptProgressTracker) finish(session *relaySession, callID string, result postAcceptProgress) { + if t == nil || session == nil || callID == "" { + return + } + key := postAcceptProgressKey{session: session, callID: callID} + expiresAt := time.Now().Add(postAcceptProgressTTL) + + t.Lock() + progress := t.calls[key] + if progress == nil { + t.Unlock() + return + } + progress.running = false + progress.transportSent = result.transportSent + progress.muteSent = result.muteSent + progress.expiresAt = expiresAt + t.Unlock() + + time.AfterFunc(postAcceptProgressTTL, func() { + t.Lock() + defer t.Unlock() + current := t.calls[key] + if current != nil && !current.running && !current.expiresAt.After(expiresAt) { + delete(t.calls, key) + } + }) +} + +func (t *postAcceptProgressTracker) reset() { + if t == nil { + return + } + t.Lock() + t.calls = make(map[postAcceptProgressKey]*postAcceptProgress) + t.Unlock() +} // sendOutgoingPostAccept completes the signaling sequence used by WhatsApp // after the remote party accepts an outgoing call. The relay connection may // start in parallel; these stanzas must not block media startup. +// +// WhatsApp can emit duplicate CallAccept events. Progress is therefore tracked +// per relay session and call: a successful transport announcement is not sent +// again when only the mute synchronization needs retrying. func (s *relaySession) sendOutgoingPostAccept(callID string) { if s == nil || s.source == nil || callID == "" { return @@ -49,18 +170,31 @@ func (s *relaySession) sendOutgoingPostAccept(callID string) { return } + progress, acquired := outgoingPostAcceptProgress.begin(s, callID) + if !acquired { + return + } + defer func() { + outgoingPostAcceptProgress.finish(s, callID, progress) + }() + ctx, cancel := context.WithTimeout(context.Background(), postAcceptSignalingTimeout) defer cancel() - socket := wa.NewSocket(client) - peer = socket.ResolveLIDForPN(ctx, peer) + peer = resolvePostAcceptPeer(ctx, client, peer) - if err = socket.SendNode(ctx, signaling.BuildPostAcceptTransportStanza(peer, creator, callID)); err != nil { - s.log.Warn("WhatsApp post-accept transport failed", "instance", s.instanceID, "call_id", callID, "err", err) - return + if !progress.transportSent { + if err = sendPostAcceptTransport(ctx, client, peer, creator, callID); err != nil { + s.log.Warn("WhatsApp post-accept transport failed", "instance", s.instanceID, "call_id", callID, "err", err) + return + } + progress.transportSent = true } - if err = socket.SendNode(ctx, signaling.BuildMuteV2Stanza(peer, creator, callID, 0)); err != nil { - s.log.Warn("WhatsApp post-accept mute sync failed", "instance", s.instanceID, "call_id", callID, "err", err) - return + if !progress.muteSent { + if err = sendPostAcceptMute(ctx, client, peer, creator, callID); err != nil { + s.log.Warn("WhatsApp post-accept mute sync failed", "instance", s.instanceID, "call_id", callID, "err", err) + return + } + progress.muteSent = true } s.log.Info("WhatsApp post-accept media signaling sent", "instance", s.instanceID, "call_id", callID, "peer", peer.String()) } From 4d0d154c3493302eb74093f45bfa16009427be4d Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:09:56 -0300 Subject: [PATCH 207/266] test(call): cover idempotent post-accept signaling --- .../media/post_accept_idempotency_test.go | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 pkg/call/voip/media/post_accept_idempotency_test.go diff --git a/pkg/call/voip/media/post_accept_idempotency_test.go b/pkg/call/voip/media/post_accept_idempotency_test.go new file mode 100644 index 00000000..c3594d6f --- /dev/null +++ b/pkg/call/voip/media/post_accept_idempotency_test.go @@ -0,0 +1,103 @@ +package media + +import ( + "context" + "errors" + "testing" + + call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" + "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" +) + +func installPostAcceptTestHooks( + t *testing.T, + transport func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error, + mute func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error, +) { + t.Helper() + previousResolve := resolvePostAcceptPeer + previousTransport := sendPostAcceptTransport + previousMute := sendPostAcceptMute + outgoingPostAcceptProgress.reset() + + resolvePostAcceptPeer = func(_ context.Context, _ *whatsmeow.Client, peer types.JID) types.JID { + return peer + } + sendPostAcceptTransport = transport + sendPostAcceptMute = mute + + t.Cleanup(func() { + resolvePostAcceptPeer = previousResolve + sendPostAcceptTransport = previousTransport + sendPostAcceptMute = previousMute + outgoingPostAcceptProgress.reset() + }) +} + +func newPostAcceptTestSession(t *testing.T, callID string) *relaySession { + t.Helper() + state := call_state.NewOutgoing( + callID, + "5511888888888:2@s.whatsapp.net", + "5511999999999:1@s.whatsapp.net", + core.CallMediaTypeAudio, + ) + source := &fakeNegotiationSource{state: state} + session := newTestRelaySession(source, &fakeRelayTransport{}) + session.client = &whatsmeow.Client{} + return session +} + +func TestOutgoingPostAcceptSuppressesDuplicateAccept(t *testing.T) { + transportCalls := 0 + muteCalls := 0 + installPostAcceptTestHooks(t, + func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { + transportCalls++ + return nil + }, + func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { + muteCalls++ + return nil + }, + ) + + session := newPostAcceptTestSession(t, "call-duplicate-accept") + session.sendOutgoingPostAccept("call-duplicate-accept") + session.sendOutgoingPostAccept("call-duplicate-accept") + + if transportCalls != 1 || muteCalls != 1 { + t.Fatalf("duplicate accept resent signaling: transport=%d mute=%d", transportCalls, muteCalls) + } +} + +func TestOutgoingPostAcceptRetriesOnlyFailedMuteStage(t *testing.T) { + transportCalls := 0 + muteCalls := 0 + installPostAcceptTestHooks(t, + func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { + transportCalls++ + return nil + }, + func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { + muteCalls++ + if muteCalls == 1 { + return errors.New("temporary mute sync failure") + } + return nil + }, + ) + + session := newPostAcceptTestSession(t, "call-mute-retry") + session.sendOutgoingPostAccept("call-mute-retry") + session.sendOutgoingPostAccept("call-mute-retry") + + if transportCalls != 1 { + t.Fatalf("successful transport stage was resent: %d", transportCalls) + } + if muteCalls != 2 { + t.Fatalf("failed mute stage was not retried once: %d", muteCalls) + } +} From 7dabedb54cd81d1e9581d6f9288e7c84f475122b Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:11:50 -0300 Subject: [PATCH 208/266] test(call): cover concurrent duplicate accepts --- .../media/post_accept_idempotency_test.go | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/pkg/call/voip/media/post_accept_idempotency_test.go b/pkg/call/voip/media/post_accept_idempotency_test.go index c3594d6f..0e77b2fd 100644 --- a/pkg/call/voip/media/post_accept_idempotency_test.go +++ b/pkg/call/voip/media/post_accept_idempotency_test.go @@ -3,6 +3,8 @@ package media import ( "context" "errors" + "sync" + "sync/atomic" "testing" call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" @@ -101,3 +103,51 @@ func TestOutgoingPostAcceptRetriesOnlyFailedMuteStage(t *testing.T) { t.Fatalf("failed mute stage was not retried once: %d", muteCalls) } } + +func TestOutgoingPostAcceptSerializesConcurrentAccepts(t *testing.T) { + var transportCalls atomic.Int32 + var muteCalls atomic.Int32 + transportStarted := make(chan struct{}) + releaseTransport := make(chan struct{}) + var startOnce sync.Once + + installPostAcceptTestHooks(t, + func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { + transportCalls.Add(1) + startOnce.Do(func() { close(transportStarted) }) + <-releaseTransport + return nil + }, + func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { + muteCalls.Add(1) + return nil + }, + ) + + session := newPostAcceptTestSession(t, "call-concurrent-accept") + primaryDone := make(chan struct{}) + go func() { + session.sendOutgoingPostAccept("call-concurrent-accept") + close(primaryDone) + }() + <-transportStarted + + var duplicates sync.WaitGroup + for range 16 { + duplicates.Add(1) + go func() { + defer duplicates.Done() + session.sendOutgoingPostAccept("call-concurrent-accept") + }() + } + duplicates.Wait() + close(releaseTransport) + <-primaryDone + + if got := transportCalls.Load(); got != 1 { + t.Fatalf("concurrent accepts sent transport %d times", got) + } + if got := muteCalls.Load(); got != 1 { + t.Fatalf("concurrent accepts sent mute %d times", got) + } +} From 9e2c69ea2100794e543effa500a36895385216a3 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:13:39 -0300 Subject: [PATCH 209/266] fix(call): scope peer key cleanup to active client --- pkg/call/voip/media/peer_call_key.go | 34 +++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/pkg/call/voip/media/peer_call_key.go b/pkg/call/voip/media/peer_call_key.go index 033843c3..0595046e 100644 --- a/pkg/call/voip/media/peer_call_key.go +++ b/pkg/call/voip/media/peer_call_key.go @@ -73,13 +73,13 @@ func attachPeerCallKeyObserver(registry *PacketRegistry, instanceID string, clie case *events.CallAccept: capturePeerCallKey(registry, instanceID, client, event) case *events.CallReject: - removePeerCallKey(registry, instanceID, event.CallID) + removePeerCallKeyForClient(registry, instanceID, client, event.CallID) case *events.CallTerminate: - removePeerCallKey(registry, instanceID, event.CallID) + removePeerCallKeyForClient(registry, instanceID, client, event.CallID) case *events.Disconnected: - clearPeerCallKeys(registry, instanceID) + clearPeerCallKeysForClient(registry, instanceID, client) case *events.LoggedOut: - clearPeerCallKeys(registry, instanceID) + clearPeerCallKeysForClient(registry, instanceID, client) } }) @@ -207,12 +207,23 @@ func peerCallKey(registry *PacketRegistry, instanceID, callID string) ([]byte, [ } func removePeerCallKey(registry *PacketRegistry, instanceID, callID string) { + removePeerCallKeyMatchingClient(registry, instanceID, nil, callID) +} + +func removePeerCallKeyForClient(registry *PacketRegistry, instanceID string, client *whatsmeow.Client, callID string) { + if client == nil { + return + } + removePeerCallKeyMatchingClient(registry, instanceID, client, callID) +} + +func removePeerCallKeyMatchingClient(registry *PacketRegistry, instanceID string, client *whatsmeow.Client, callID string) { if registry == nil || instanceID == "" || callID == "" { return } peerCallKeyObservers.Lock() observer := peerCallKeyObservers.registries[registry][instanceID] - if observer != nil { + if observer != nil && (client == nil || observer.client == client) { if stored, ok := observer.keys[callID]; ok { zeroBytes(stored.key) delete(observer.keys, callID) @@ -222,12 +233,23 @@ func removePeerCallKey(registry *PacketRegistry, instanceID, callID string) { } func clearPeerCallKeys(registry *PacketRegistry, instanceID string) { + clearPeerCallKeysMatchingClient(registry, instanceID, nil) +} + +func clearPeerCallKeysForClient(registry *PacketRegistry, instanceID string, client *whatsmeow.Client) { + if client == nil { + return + } + clearPeerCallKeysMatchingClient(registry, instanceID, client) +} + +func clearPeerCallKeysMatchingClient(registry *PacketRegistry, instanceID string, client *whatsmeow.Client) { if registry == nil || instanceID == "" { return } peerCallKeyObservers.Lock() observer := peerCallKeyObservers.registries[registry][instanceID] - if observer != nil { + if observer != nil && (client == nil || observer.client == client) { for callID, stored := range observer.keys { zeroBytes(stored.key) delete(observer.keys, callID) From 4c6f839112453ba4f56e327758cd4c843303b936 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:14:32 -0300 Subject: [PATCH 210/266] test(call): protect replacement keys from stale clients --- pkg/call/voip/media/peer_call_key_test.go | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pkg/call/voip/media/peer_call_key_test.go b/pkg/call/voip/media/peer_call_key_test.go index 150eaba8..17de827c 100644 --- a/pkg/call/voip/media/peer_call_key_test.go +++ b/pkg/call/voip/media/peer_call_key_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" + "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" ) @@ -64,3 +65,42 @@ func TestEqualCallKeyPeersIsOrderSensitive(t *testing.T) { t.Fatal("different peer priority order was treated as equal") } } + +func TestStaleClientCannotDeleteReplacementPeerKeys(t *testing.T) { + registry := NewPacketRegistry(&fakePacketSource{callKey: bytes.Repeat([]byte{0x31}, 32)}) + const ( + instanceID = "replacement-instance" + callID = "replacement-call" + ) + staleClient := &whatsmeow.Client{} + activeClient := &whatsmeow.Client{} + peer := types.NewJID("5511888888888", types.HiddenUserServer) + + peerCallKeyObservers.Lock() + peerCallKeyObservers.registries[registry] = map[string]*peerCallKeyObserver{ + instanceID: { + client: activeClient, + keys: map[string]storedPeerCallKey{ + callID: { + key: bytes.Repeat([]byte{0x52}, 32), + peers: []types.JID{peer}, + }, + }, + }, + } + peerCallKeyObservers.Unlock() + defer detachPeerCallKeyObserver(registry, instanceID) + + removePeerCallKeyForClient(registry, instanceID, staleClient, callID) + clearPeerCallKeysForClient(registry, instanceID, staleClient) + key, peers, ok := peerCallKey(registry, instanceID, callID) + if !ok || len(key) != 32 || len(peers) != 1 || peers[0].String() != peer.String() { + t.Fatalf("stale client removed replacement key: ok=%v key=%d peers=%#v", ok, len(key), peers) + } + zeroBytes(key) + + removePeerCallKeyForClient(registry, instanceID, activeClient, callID) + if _, _, ok = peerCallKey(registry, instanceID, callID); ok { + t.Fatal("active client failed to remove its own peer key") + } +} From 5ff264b30ad3ea4600df161d30ef18e7b3296659 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:18:00 -0300 Subject: [PATCH 211/266] refactor(call): reuse post-accept socket adapter --- pkg/call/voip/media/post_accept.go | 42 +++++++++++++----------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/pkg/call/voip/media/post_accept.go b/pkg/call/voip/media/post_accept.go index f8f517c8..819bca8d 100644 --- a/pkg/call/voip/media/post_accept.go +++ b/pkg/call/voip/media/post_accept.go @@ -10,6 +10,7 @@ import ( "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling" "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa" "go.mau.fi/whatsmeow" + waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/types" ) @@ -18,6 +19,11 @@ const ( postAcceptProgressTTL = 10 * time.Minute ) +type postAcceptSocket interface { + ResolveLIDForPN(context.Context, types.JID) types.JID + SendNode(context.Context, waBinary.Node) error +} + type postAcceptProgressKey struct { session *relaySession callID string @@ -39,26 +45,8 @@ var outgoingPostAcceptProgress = postAcceptProgressTracker{ calls: make(map[postAcceptProgressKey]*postAcceptProgress), } -var resolvePostAcceptPeer = func(ctx context.Context, client *whatsmeow.Client, peer types.JID) types.JID { - return wa.NewSocket(client).ResolveLIDForPN(ctx, peer) -} - -var sendPostAcceptTransport = func( - ctx context.Context, - client *whatsmeow.Client, - peer, creator types.JID, - callID string, -) error { - return wa.NewSocket(client).SendNode(ctx, signaling.BuildPostAcceptTransportStanza(peer, creator, callID)) -} - -var sendPostAcceptMute = func( - ctx context.Context, - client *whatsmeow.Client, - peer, creator types.JID, - callID string, -) error { - return wa.NewSocket(client).SendNode(ctx, signaling.BuildMuteV2Stanza(peer, creator, callID, 0)) +var newPostAcceptSocket = func(client *whatsmeow.Client) postAcceptSocket { + return wa.NewSocket(client) } func (t *postAcceptProgressTracker) begin(session *relaySession, callID string) (postAcceptProgress, bool) { @@ -84,7 +72,7 @@ func (t *postAcceptProgressTracker) begin(session *relaySession, callID string) progress = &postAcceptProgress{} t.calls[key] = progress } - if progress.running || progress.muteSent { + if progress.running || (progress.transportSent && progress.muteSent) { return postAcceptProgress{}, false } progress.running = true @@ -170,6 +158,12 @@ func (s *relaySession) sendOutgoingPostAccept(callID string) { return } + socket := newPostAcceptSocket(client) + if socket == nil { + s.log.Warn("WhatsApp post-accept signaling skipped", "instance", s.instanceID, "call_id", callID, "err", "nil socket adapter") + return + } + progress, acquired := outgoingPostAcceptProgress.begin(s, callID) if !acquired { return @@ -180,17 +174,17 @@ func (s *relaySession) sendOutgoingPostAccept(callID string) { ctx, cancel := context.WithTimeout(context.Background(), postAcceptSignalingTimeout) defer cancel() - peer = resolvePostAcceptPeer(ctx, client, peer) + peer = socket.ResolveLIDForPN(ctx, peer) if !progress.transportSent { - if err = sendPostAcceptTransport(ctx, client, peer, creator, callID); err != nil { + if err = socket.SendNode(ctx, signaling.BuildPostAcceptTransportStanza(peer, creator, callID)); err != nil { s.log.Warn("WhatsApp post-accept transport failed", "instance", s.instanceID, "call_id", callID, "err", err) return } progress.transportSent = true } if !progress.muteSent { - if err = sendPostAcceptMute(ctx, client, peer, creator, callID); err != nil { + if err = socket.SendNode(ctx, signaling.BuildMuteV2Stanza(peer, creator, callID, 0)); err != nil { s.log.Warn("WhatsApp post-accept mute sync failed", "instance", s.instanceID, "call_id", callID, "err", err) return } From db8a776ab18444a719a6d35fba49c3e29595c08e Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:18:47 -0300 Subject: [PATCH 212/266] test(call): exercise shared post-accept socket --- .../media/post_accept_idempotency_test.go | 109 ++++++++++-------- 1 file changed, 64 insertions(+), 45 deletions(-) diff --git a/pkg/call/voip/media/post_accept_idempotency_test.go b/pkg/call/voip/media/post_accept_idempotency_test.go index 0e77b2fd..01bbaa79 100644 --- a/pkg/call/voip/media/post_accept_idempotency_test.go +++ b/pkg/call/voip/media/post_accept_idempotency_test.go @@ -10,30 +10,38 @@ import ( call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" "go.mau.fi/whatsmeow" + waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/types" ) -func installPostAcceptTestHooks( - t *testing.T, - transport func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error, - mute func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error, -) { - t.Helper() - previousResolve := resolvePostAcceptPeer - previousTransport := sendPostAcceptTransport - previousMute := sendPostAcceptMute - outgoingPostAcceptProgress.reset() +type fakePostAcceptSocket struct { + resolve func(context.Context, types.JID) types.JID + send func(context.Context, waBinary.Node) error +} - resolvePostAcceptPeer = func(_ context.Context, _ *whatsmeow.Client, peer types.JID) types.JID { - return peer +func (f *fakePostAcceptSocket) ResolveLIDForPN(ctx context.Context, peer types.JID) types.JID { + if f != nil && f.resolve != nil { + return f.resolve(ctx, peer) } - sendPostAcceptTransport = transport - sendPostAcceptMute = mute + return peer +} + +func (f *fakePostAcceptSocket) SendNode(ctx context.Context, node waBinary.Node) error { + if f != nil && f.send != nil { + return f.send(ctx, node) + } + return nil +} +func installPostAcceptTestSocket(t *testing.T, socket postAcceptSocket) { + t.Helper() + previousFactory := newPostAcceptSocket + outgoingPostAcceptProgress.reset() + newPostAcceptSocket = func(*whatsmeow.Client) postAcceptSocket { + return socket + } t.Cleanup(func() { - resolvePostAcceptPeer = previousResolve - sendPostAcceptTransport = previousTransport - sendPostAcceptMute = previousMute + newPostAcceptSocket = previousFactory outgoingPostAcceptProgress.reset() }) } @@ -52,19 +60,28 @@ func newPostAcceptTestSession(t *testing.T, callID string) *relaySession { return session } +func postAcceptChildTag(node waBinary.Node) string { + children := node.GetChildren() + if len(children) == 0 { + return "" + } + return children[0].Tag +} + func TestOutgoingPostAcceptSuppressesDuplicateAccept(t *testing.T) { transportCalls := 0 muteCalls := 0 - installPostAcceptTestHooks(t, - func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { - transportCalls++ - return nil - }, - func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { - muteCalls++ + installPostAcceptTestSocket(t, &fakePostAcceptSocket{ + send: func(_ context.Context, node waBinary.Node) error { + switch postAcceptChildTag(node) { + case "transport": + transportCalls++ + case "mute_v2": + muteCalls++ + } return nil }, - ) + }) session := newPostAcceptTestSession(t, "call-duplicate-accept") session.sendOutgoingPostAccept("call-duplicate-accept") @@ -78,19 +95,20 @@ func TestOutgoingPostAcceptSuppressesDuplicateAccept(t *testing.T) { func TestOutgoingPostAcceptRetriesOnlyFailedMuteStage(t *testing.T) { transportCalls := 0 muteCalls := 0 - installPostAcceptTestHooks(t, - func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { - transportCalls++ - return nil - }, - func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { - muteCalls++ - if muteCalls == 1 { - return errors.New("temporary mute sync failure") + installPostAcceptTestSocket(t, &fakePostAcceptSocket{ + send: func(_ context.Context, node waBinary.Node) error { + switch postAcceptChildTag(node) { + case "transport": + transportCalls++ + case "mute_v2": + muteCalls++ + if muteCalls == 1 { + return errors.New("temporary mute sync failure") + } } return nil }, - ) + }) session := newPostAcceptTestSession(t, "call-mute-retry") session.sendOutgoingPostAccept("call-mute-retry") @@ -111,18 +129,19 @@ func TestOutgoingPostAcceptSerializesConcurrentAccepts(t *testing.T) { releaseTransport := make(chan struct{}) var startOnce sync.Once - installPostAcceptTestHooks(t, - func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { - transportCalls.Add(1) - startOnce.Do(func() { close(transportStarted) }) - <-releaseTransport - return nil - }, - func(context.Context, *whatsmeow.Client, types.JID, types.JID, string) error { - muteCalls.Add(1) + installPostAcceptTestSocket(t, &fakePostAcceptSocket{ + send: func(_ context.Context, node waBinary.Node) error { + switch postAcceptChildTag(node) { + case "transport": + transportCalls.Add(1) + startOnce.Do(func() { close(transportStarted) }) + <-releaseTransport + case "mute_v2": + muteCalls.Add(1) + } return nil }, - ) + }) session := newPostAcceptTestSession(t, "call-concurrent-accept") primaryDone := make(chan struct{}) From 5782c6eaaf2235461c40d8d3f244ddc09a076c85 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:42:09 -0300 Subject: [PATCH 213/266] fix(call): retry post-accept signaling with backoff --- pkg/call/voip/media/post_accept.go | 91 ++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/pkg/call/voip/media/post_accept.go b/pkg/call/voip/media/post_accept.go index 819bca8d..189d8866 100644 --- a/pkg/call/voip/media/post_accept.go +++ b/pkg/call/voip/media/post_accept.go @@ -19,6 +19,16 @@ const ( postAcceptProgressTTL = 10 * time.Minute ) +var postAcceptRetryDelays = []time.Duration{ + 200 * time.Millisecond, + 750 * time.Millisecond, + 2 * time.Second, +} + +var schedulePostAcceptRetry = func(delay time.Duration, callback func()) { + time.AfterFunc(delay, callback) +} + type postAcceptSocket interface { ResolveLIDForPN(context.Context, types.JID) types.JID SendNode(context.Context, waBinary.Node) error @@ -30,10 +40,12 @@ type postAcceptProgressKey struct { } type postAcceptProgress struct { - running bool - transportSent bool - muteSent bool - expiresAt time.Time + running bool + transportSent bool + muteSent bool + failedAttempts int + retryScheduled bool + expiresAt time.Time } type postAcceptProgressTracker struct { @@ -72,7 +84,7 @@ func (t *postAcceptProgressTracker) begin(session *relaySession, callID string) progress = &postAcceptProgress{} t.calls[key] = progress } - if progress.running || (progress.transportSent && progress.muteSent) { + if progress.running || progress.retryScheduled || (progress.transportSent && progress.muteSent) { return postAcceptProgress{}, false } progress.running = true @@ -80,9 +92,29 @@ func (t *postAcceptProgressTracker) begin(session *relaySession, callID string) return *progress, true } -func (t *postAcceptProgressTracker) finish(session *relaySession, callID string, result postAcceptProgress) { +func (t *postAcceptProgressTracker) retryDue(session *relaySession, callID string) bool { if t == nil || session == nil || callID == "" { - return + return false + } + key := postAcceptProgressKey{session: session, callID: callID} + t.Lock() + defer t.Unlock() + progress := t.calls[key] + if progress == nil || !progress.retryScheduled || progress.running || (progress.transportSent && progress.muteSent) { + return false + } + progress.retryScheduled = false + return true +} + +func (t *postAcceptProgressTracker) finish( + session *relaySession, + callID string, + result postAcceptProgress, + failed bool, +) (retryDelay time.Duration, scheduleRetry, exhausted bool) { + if t == nil || session == nil || callID == "" { + return 0, false, false } key := postAcceptProgressKey{session: session, callID: callID} expiresAt := time.Now().Add(postAcceptProgressTTL) @@ -91,12 +123,26 @@ func (t *postAcceptProgressTracker) finish(session *relaySession, callID string, progress := t.calls[key] if progress == nil { t.Unlock() - return + return 0, false, false } progress.running = false progress.transportSent = result.transportSent progress.muteSent = result.muteSent progress.expiresAt = expiresAt + + if progress.transportSent && progress.muteSent { + progress.failedAttempts = 0 + progress.retryScheduled = false + } else if failed && !progress.retryScheduled { + if progress.failedAttempts < len(postAcceptRetryDelays) { + retryDelay = postAcceptRetryDelays[progress.failedAttempts] + progress.failedAttempts++ + progress.retryScheduled = true + scheduleRetry = true + } else { + exhausted = true + } + } t.Unlock() time.AfterFunc(postAcceptProgressTTL, func() { @@ -107,6 +153,7 @@ func (t *postAcceptProgressTracker) finish(session *relaySession, callID string, delete(t.calls, key) } }) + return retryDelay, scheduleRetry, exhausted } func (t *postAcceptProgressTracker) reset() { @@ -124,7 +171,9 @@ func (t *postAcceptProgressTracker) reset() { // // WhatsApp can emit duplicate CallAccept events. Progress is therefore tracked // per relay session and call: a successful transport announcement is not sent -// again when only the mute synchronization needs retrying. +// again when only the mute synchronization needs retrying. Transient send +// failures are retried internally, so recovery does not depend on WhatsApp +// emitting another CallAccept event. func (s *relaySession) sendOutgoingPostAccept(callID string) { if s == nil || s.source == nil || callID == "" { return @@ -168,8 +217,28 @@ func (s *relaySession) sendOutgoingPostAccept(callID string) { if !acquired { return } + failed := false defer func() { - outgoingPostAcceptProgress.finish(s, callID, progress) + delay, shouldRetry, exhausted := outgoingPostAcceptProgress.finish(s, callID, progress, failed) + if shouldRetry { + s.log.Debug("WhatsApp post-accept signaling retry scheduled", + "instance", s.instanceID, + "call_id", callID, + "delay", delay, + ) + schedulePostAcceptRetry(delay, func() { + if outgoingPostAcceptProgress.retryDue(s, callID) { + s.sendOutgoingPostAccept(callID) + } + }) + } else if exhausted { + s.log.Warn("WhatsApp post-accept signaling retries exhausted", + "instance", s.instanceID, + "call_id", callID, + "transport_sent", progress.transportSent, + "mute_sent", progress.muteSent, + ) + } }() ctx, cancel := context.WithTimeout(context.Background(), postAcceptSignalingTimeout) @@ -178,6 +247,7 @@ func (s *relaySession) sendOutgoingPostAccept(callID string) { if !progress.transportSent { if err = socket.SendNode(ctx, signaling.BuildPostAcceptTransportStanza(peer, creator, callID)); err != nil { + failed = true s.log.Warn("WhatsApp post-accept transport failed", "instance", s.instanceID, "call_id", callID, "err", err) return } @@ -185,6 +255,7 @@ func (s *relaySession) sendOutgoingPostAccept(callID string) { } if !progress.muteSent { if err = socket.SendNode(ctx, signaling.BuildMuteV2Stanza(peer, creator, callID, 0)); err != nil { + failed = true s.log.Warn("WhatsApp post-accept mute sync failed", "instance", s.instanceID, "call_id", callID, "err", err) return } From 4265d950602fb65598be81d8663f1e7d6a48e73a Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:43:07 -0300 Subject: [PATCH 214/266] test(call): cover automatic post-accept retries --- .../media/post_accept_idempotency_test.go | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/pkg/call/voip/media/post_accept_idempotency_test.go b/pkg/call/voip/media/post_accept_idempotency_test.go index 01bbaa79..2bf93ac7 100644 --- a/pkg/call/voip/media/post_accept_idempotency_test.go +++ b/pkg/call/voip/media/post_accept_idempotency_test.go @@ -6,6 +6,7 @@ import ( "sync" "sync/atomic" "testing" + "time" call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call" "github.com/evolution-foundation/evolution-go/pkg/call/voip/core" @@ -36,12 +37,21 @@ func (f *fakePostAcceptSocket) SendNode(ctx context.Context, node waBinary.Node) func installPostAcceptTestSocket(t *testing.T, socket postAcceptSocket) { t.Helper() previousFactory := newPostAcceptSocket + previousScheduler := schedulePostAcceptRetry + previousDelays := append([]time.Duration(nil), postAcceptRetryDelays...) outgoingPostAcceptProgress.reset() newPostAcceptSocket = func(*whatsmeow.Client) postAcceptSocket { return socket } + // Execute retries synchronously so tests prove the state machine without + // sleeping or leaving timers alive after cleanup. + schedulePostAcceptRetry = func(_ time.Duration, callback func()) { + callback() + } t.Cleanup(func() { newPostAcceptSocket = previousFactory + schedulePostAcceptRetry = previousScheduler + postAcceptRetryDelays = previousDelays outgoingPostAcceptProgress.reset() }) } @@ -92,7 +102,7 @@ func TestOutgoingPostAcceptSuppressesDuplicateAccept(t *testing.T) { } } -func TestOutgoingPostAcceptRetriesOnlyFailedMuteStage(t *testing.T) { +func TestOutgoingPostAcceptAutomaticallyRetriesOnlyFailedMuteStage(t *testing.T) { transportCalls := 0 muteCalls := 0 installPostAcceptTestSocket(t, &fakePostAcceptSocket{ @@ -111,14 +121,42 @@ func TestOutgoingPostAcceptRetriesOnlyFailedMuteStage(t *testing.T) { }) session := newPostAcceptTestSession(t, "call-mute-retry") - session.sendOutgoingPostAccept("call-mute-retry") + // No duplicate CallAccept is injected: the internal scheduler must recover. session.sendOutgoingPostAccept("call-mute-retry") if transportCalls != 1 { t.Fatalf("successful transport stage was resent: %d", transportCalls) } if muteCalls != 2 { - t.Fatalf("failed mute stage was not retried once: %d", muteCalls) + t.Fatalf("failed mute stage was not automatically retried once: %d", muteCalls) + } +} + +func TestOutgoingPostAcceptStopsAfterRetryBudget(t *testing.T) { + transportCalls := 0 + muteCalls := 0 + installPostAcceptTestSocket(t, &fakePostAcceptSocket{ + send: func(_ context.Context, node waBinary.Node) error { + switch postAcceptChildTag(node) { + case "transport": + transportCalls++ + return errors.New("persistent transport failure") + case "mute_v2": + muteCalls++ + } + return nil + }, + }) + + session := newPostAcceptTestSession(t, "call-retry-budget") + session.sendOutgoingPostAccept("call-retry-budget") + + wantAttempts := 1 + len(postAcceptRetryDelays) + if transportCalls != wantAttempts { + t.Fatalf("unexpected retry count: got=%d want=%d", transportCalls, wantAttempts) + } + if muteCalls != 0 { + t.Fatalf("mute stage ran before transport succeeded: %d", muteCalls) } } From a521ff171bb9f46272d7edc730e3b5b86bbb2096 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:48:20 -0300 Subject: [PATCH 215/266] feat(call): prepare validated accept receipt builder --- pkg/call/voip/signaling/post_accept.go | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/pkg/call/voip/signaling/post_accept.go b/pkg/call/voip/signaling/post_accept.go index e7048e55..9710031a 100644 --- a/pkg/call/voip/signaling/post_accept.go +++ b/pkg/call/voip/signaling/post_accept.go @@ -53,3 +53,48 @@ func BuildMuteV2Stanza(peer, creator types.JID, callID string, muteState int) wa }}, } } + +// BuildAcceptReceiptStanza builds the device receipt expected after a remote +// CallAccept. acceptMessageID MUST be the original ID from the outer incoming +// stanza. It must never be generated locally or replaced by callID. +// +// The currently pinned whatsmeow event API does not expose that outer ID, so +// this helper is intentionally not wired into media signaling until the source +// event can provide it without reflection or unsafe access. +func BuildAcceptReceiptStanza( + peer types.JID, + acceptMessageID, callID string, + creator, own types.JID, +) (waBinary.Node, error) { + if peer.IsEmpty() { + return waBinary.Node{}, fmt.Errorf("accept receipt peer JID is empty") + } + if own.IsEmpty() { + return waBinary.Node{}, fmt.Errorf("accept receipt own JID is empty") + } + if creator.IsEmpty() { + return waBinary.Node{}, fmt.Errorf("accept receipt creator JID is empty") + } + if acceptMessageID == "" { + return waBinary.Node{}, fmt.Errorf("accept receipt requires original stanza ID") + } + if callID == "" { + return waBinary.Node{}, fmt.Errorf("accept receipt call ID is empty") + } + + return waBinary.Node{ + Tag: "receipt", + Attrs: waBinary.Attrs{ + "to": peer, + "id": acceptMessageID, + "from": own, + }, + Content: []waBinary.Node{{ + Tag: "accept", + Attrs: waBinary.Attrs{ + "call-id": callID, + "call-creator": creator, + }, + }}, + }, nil +} From a281bacc31e570207bba8c589f290b480a82c80d Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:49:05 -0300 Subject: [PATCH 216/266] test(call): validate accept receipt stanza --- pkg/call/voip/signaling/post_accept_test.go | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/pkg/call/voip/signaling/post_accept_test.go b/pkg/call/voip/signaling/post_accept_test.go index 53afa09b..62529a57 100644 --- a/pkg/call/voip/signaling/post_accept_test.go +++ b/pkg/call/voip/signaling/post_accept_test.go @@ -51,3 +51,72 @@ func TestBuildMuteV2Stanza(t *testing.T) { t.Fatalf("unexpected call ID: %s", wanode.AttrString(children[0].Attrs, "call-id")) } } + +func TestBuildAcceptReceiptStanza(t *testing.T) { + peer := types.NewADJID("5511999999999", 0, 7) + creator := types.NewJID("5511999999999", types.HiddenUserServer) + own := types.NewADJID("5511000000000", 0, 3) + + node, err := BuildAcceptReceiptStanza(peer, "ACCEPT-STANZA-ID", "CALL-RECEIPT", creator, own) + if err != nil { + t.Fatal(err) + } + if node.Tag != "receipt" { + t.Fatalf("unexpected receipt tag: %s", node.Tag) + } + if got := wanode.AttrString(node.Attrs, "id"); got != "ACCEPT-STANZA-ID" { + t.Fatalf("receipt did not preserve outer stanza ID: %s", got) + } + if got, ok := node.Attrs["to"].(types.JID); !ok || got.String() != peer.String() { + t.Fatalf("unexpected receipt target: %#v", node.Attrs["to"]) + } + if got, ok := node.Attrs["from"].(types.JID); !ok || got.String() != own.String() { + t.Fatalf("unexpected receipt sender: %#v", node.Attrs["from"]) + } + + children := wanode.NodeChildren(&node) + if len(children) != 1 || children[0].Tag != "accept" { + t.Fatalf("unexpected receipt content: %#v", children) + } + if got := wanode.AttrString(children[0].Attrs, "call-id"); got != "CALL-RECEIPT" { + t.Fatalf("unexpected receipt call ID: %s", got) + } + if got, ok := children[0].Attrs["call-creator"].(types.JID); !ok || got.String() != creator.String() { + t.Fatalf("unexpected receipt creator: %#v", children[0].Attrs["call-creator"]) + } +} + +func TestBuildAcceptReceiptStanzaRejectsSyntheticOrIncompleteInput(t *testing.T) { + peer := types.NewJID("5511999999999", types.HiddenUserServer) + creator := types.NewJID("5511999999999", types.HiddenUserServer) + own := types.NewJID("5511000000000", types.HiddenUserServer) + + tests := []struct { + name string + peer types.JID + acceptMessageID string + callID string + creator types.JID + own types.JID + }{ + {name: "missing outer stanza ID", peer: peer, callID: "CALL", creator: creator, own: own}, + {name: "missing call ID", peer: peer, acceptMessageID: "ACCEPT", creator: creator, own: own}, + {name: "missing peer", acceptMessageID: "ACCEPT", callID: "CALL", creator: creator, own: own}, + {name: "missing creator", peer: peer, acceptMessageID: "ACCEPT", callID: "CALL", own: own}, + {name: "missing own JID", peer: peer, acceptMessageID: "ACCEPT", callID: "CALL", creator: creator}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := BuildAcceptReceiptStanza( + test.peer, + test.acceptMessageID, + test.callID, + test.creator, + test.own, + ); err == nil { + t.Fatal("expected validation error") + } + }) + } +} From 43fba9dc517e669a6fb6a24e23b568615be7a008 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:51:54 -0300 Subject: [PATCH 217/266] feat(call): add runtime negotiation watchdog --- pkg/call/runtime/watchdog.go | 166 +++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 pkg/call/runtime/watchdog.go diff --git a/pkg/call/runtime/watchdog.go b/pkg/call/runtime/watchdog.go new file mode 100644 index 00000000..5ca0a0c2 --- /dev/null +++ b/pkg/call/runtime/watchdog.go @@ -0,0 +1,166 @@ +package call_runtime + +import ( + "sync" + "time" +) + +var ( + ringingWatchdogTimeout = 90 * time.Second + connectingWatchdogTimeout = 45 * time.Second +) + +type runtimeWatchdogEntry struct { + timer *time.Timer + generation uint64 + state State + updatedAt time.Time +} + +type runtimeWatchdogState struct { + onTimeout func(instanceID, callID string) + entries map[string]runtimeWatchdogEntry + generation uint64 +} + +var runtimeWatchdogs = struct { + sync.Mutex + states map[*Runtime]*runtimeWatchdogState +}{states: make(map[*Runtime]*runtimeWatchdogState)} + +// SetOnTimeout registers the cleanup callback invoked after a ringing or media +// negotiation timeout. The runtime updates the public call to StateFailed +// before invoking this callback. +func (r *Runtime) SetOnTimeout(callback func(instanceID, callID string)) { + if r == nil { + return + } + runtimeWatchdogs.Lock() + state := runtimeWatchdogs.states[r] + if state == nil { + state = &runtimeWatchdogState{entries: make(map[string]runtimeWatchdogEntry)} + runtimeWatchdogs.states[r] = state + } + state.onTimeout = callback + runtimeWatchdogs.Unlock() +} + +func (r *Runtime) syncWatchdog(call Call) { + if r == nil || call.ID == "" { + return + } + + timeout, reason := watchdogTimeout(call.State) + if timeout <= 0 { + r.cancelWatchdog(call.ID) + return + } + + runtimeWatchdogs.Lock() + state := runtimeWatchdogs.states[r] + if state == nil { + state = &runtimeWatchdogState{entries: make(map[string]runtimeWatchdogEntry)} + runtimeWatchdogs.states[r] = state + } + if previous, ok := state.entries[call.ID]; ok && previous.timer != nil { + previous.timer.Stop() + } + state.generation++ + generation := state.generation + entry := runtimeWatchdogEntry{ + generation: generation, + state: call.State, + updatedAt: call.UpdatedAt, + } + entry.timer = time.AfterFunc(timeout, func() { + r.expireWatchdog(call.ID, generation, call.State, call.UpdatedAt, reason) + }) + state.entries[call.ID] = entry + runtimeWatchdogs.Unlock() +} + +func watchdogTimeout(state State) (time.Duration, string) { + switch state { + case StateRinging: + return ringingWatchdogTimeout, "call ringing timed out" + case StateConnecting: + return connectingWatchdogTimeout, "call media negotiation timed out" + default: + return 0, "" + } +} + +func (r *Runtime) expireWatchdog(callID string, generation uint64, expectedState State, expectedUpdatedAt time.Time, reason string) { + if r == nil || callID == "" { + return + } + + runtimeWatchdogs.Lock() + watchdogState := runtimeWatchdogs.states[r] + if watchdogState == nil { + runtimeWatchdogs.Unlock() + return + } + entry, ok := watchdogState.entries[callID] + if !ok || entry.generation != generation { + runtimeWatchdogs.Unlock() + return + } + delete(watchdogState.entries, callID) + callback := watchdogState.onTimeout + runtimeWatchdogs.Unlock() + + r.mu.Lock() + call, ok := r.calls[callID] + if !ok || call.State != expectedState || !call.UpdatedAt.Equal(expectedUpdatedAt) { + r.mu.Unlock() + return + } + call.State = StateFailed + call.Error = reason + call.EndReason = "timeout" + call.UpdatedAt = time.Now().UTC() + r.calls[callID] = call + instanceID := r.instanceID + r.mu.Unlock() + + if callback != nil { + callback(instanceID, callID) + } +} + +func (r *Runtime) cancelWatchdog(callID string) { + if r == nil || callID == "" { + return + } + runtimeWatchdogs.Lock() + state := runtimeWatchdogs.states[r] + if state != nil { + if entry, ok := state.entries[callID]; ok { + if entry.timer != nil { + entry.timer.Stop() + } + delete(state.entries, callID) + } + } + runtimeWatchdogs.Unlock() +} + +func (r *Runtime) closeWatchdogs() { + if r == nil { + return + } + runtimeWatchdogs.Lock() + state := runtimeWatchdogs.states[r] + delete(runtimeWatchdogs.states, r) + if state != nil { + for callID, entry := range state.entries { + if entry.timer != nil { + entry.timer.Stop() + } + delete(state.entries, callID) + } + state.onTimeout = nil + } + runtimeWatchdogs.Unlock() +} From b35a90b58f24880fdbc9bcb4e1c248b780515b9c Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:52:45 -0300 Subject: [PATCH 218/266] feat(call): arm watchdog from runtime transitions --- pkg/call/runtime/runtime.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/call/runtime/runtime.go b/pkg/call/runtime/runtime.go index 9ecd07ac..5445cf4c 100644 --- a/pkg/call/runtime/runtime.go +++ b/pkg/call/runtime/runtime.go @@ -126,13 +126,12 @@ func (r *Runtime) Close() { if client != nil && handlerID != 0 { client.RemoveEventHandler(handlerID) } + r.closeWatchdogs() } // UpsertCall creates or updates a call while preserving its creation time. func (r *Runtime) UpsertCall(call Call) { r.mu.Lock() - defer r.mu.Unlock() - now := time.Now().UTC() if current, ok := r.calls[call.ID]; ok { if call.CreatedAt.IsZero() { @@ -149,6 +148,8 @@ func (r *Runtime) UpsertCall(call Call) { call.UpdatedAt = now } r.calls[call.ID] = call + r.mu.Unlock() + r.syncWatchdog(call) } // Transition applies a partial lifecycle update without erasing metadata that @@ -159,8 +160,6 @@ func (r *Runtime) Transition(callID, peer string, direction Direction, state Sta } r.mu.Lock() - defer r.mu.Unlock() - now := time.Now().UTC() call, exists := r.calls[callID] if !exists { @@ -186,6 +185,8 @@ func (r *Runtime) Transition(callID, peer string, direction Direction, state Sta } call.UpdatedAt = now r.calls[callID] = call + r.mu.Unlock() + r.syncWatchdog(call) } func shouldReplacePeer(current, candidate string) bool { @@ -212,6 +213,7 @@ func (r *Runtime) RemoveCall(callID string) { r.mu.Lock() delete(r.calls, callID) r.mu.Unlock() + r.cancelWatchdog(callID) } func (r *Runtime) Snapshot() Snapshot { @@ -384,9 +386,8 @@ func isOwnJID(client *whatsmeow.Client, jid types.JID) bool { func (r *Runtime) failOpenCalls(reason string) { r.mu.Lock() - defer r.mu.Unlock() - now := time.Now().UTC() + failedCallIDs := make([]string, 0) for callID, call := range r.calls { if call.State == StateEnded || call.State == StateFailed { continue @@ -395,6 +396,11 @@ func (r *Runtime) failOpenCalls(reason string) { call.Error = reason call.UpdatedAt = now r.calls[callID] = call + failedCallIDs = append(failedCallIDs, callID) + } + r.mu.Unlock() + for _, callID := range failedCallIDs { + r.cancelWatchdog(callID) } } @@ -419,7 +425,6 @@ func callNodeContainsVideo(node *waBinary.Node) bool { if callNodeContainsVideo(&content[index]) { return true } - } case *waBinary.Node: return callNodeContainsVideo(content) } From b08e2f641d1a0420b65355f430f641f792087311 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:53:52 -0300 Subject: [PATCH 219/266] feat(call): clean media resources on watchdog timeout --- pkg/call/lifecycle/coordinator.go | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go index 45524dcc..f810d840 100644 --- a/pkg/call/lifecycle/coordinator.go +++ b/pkg/call/lifecycle/coordinator.go @@ -33,7 +33,7 @@ type Coordinator struct { onCallMediaCleanup func(instanceID, callID string) onInstanceMediaCleanup func(instanceID string) - incomingEnabled map[string]bool + incomingEnabled map[string]bool mediaErrorReported map[string]bool } @@ -41,10 +41,10 @@ func NewCoordinator() *Coordinator { incoming := call_incoming.NewRegistry() packets := call_media.NewPacketRegistry(incoming) coordinator := &Coordinator{ - runtimes: call_runtime.NewRegistry(), - incoming: incoming, - packets: packets, - incomingEnabled: make(map[string]bool), + runtimes: call_runtime.NewRegistry(), + incoming: incoming, + packets: packets, + incomingEnabled: make(map[string]bool), mediaErrorReported: make(map[string]bool), } coordinator.audio = call_media.NewAudioRegistry(func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error { @@ -155,6 +155,16 @@ func (c *Coordinator) clearInstanceMediaErrors(instanceID string) { c.mu.Unlock() } +func (c *Coordinator) configureRuntime(runtime *call_runtime.Runtime) { + if c == nil || runtime == nil { + return + } + runtime.SetOnTimeout(func(instanceID, callID string) { + slog.Warn("WhatsApp call negotiation timed out", "instance", instanceID, "call_id", callID) + c.RemovePrivate(instanceID, callID) + }) +} + // AttachClient is called by the WhatsApp client lifecycle. Public call state is // always monitored. Private outgoing negotiation remains available even when // incoming offer preparation is disabled by automatic rejection settings. @@ -167,7 +177,8 @@ func (c *Coordinator) AttachClient(instanceID string, client *whatsmeow.Client, c.incomingEnabled[instanceID] = prepareIncoming c.mu.Unlock() - c.runtimes.Attach(instanceID, client) + runtime := c.runtimes.Attach(instanceID, client) + c.configureRuntime(runtime) c.incoming.Attach(instanceID, client, prepareIncoming) c.packets.Attach(instanceID, client) c.relays.Attach(instanceID, client) @@ -198,7 +209,8 @@ func (c *Coordinator) Attach(instanceID string, client *whatsmeow.Client) { if c == nil || instanceID == "" || client == nil { return } - c.runtimes.Attach(instanceID, client) + runtime := c.runtimes.Attach(instanceID, client) + c.configureRuntime(runtime) c.mu.RLock() prepareIncoming, configured := c.incomingEnabled[instanceID] From d67fb28dffd073e1500d2232359aebc5528b0813 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:54:22 -0300 Subject: [PATCH 220/266] test(call): cover negotiation watchdog lifecycle --- pkg/call/runtime/watchdog_test.go | 135 ++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 pkg/call/runtime/watchdog_test.go diff --git a/pkg/call/runtime/watchdog_test.go b/pkg/call/runtime/watchdog_test.go new file mode 100644 index 00000000..c1f9cf03 --- /dev/null +++ b/pkg/call/runtime/watchdog_test.go @@ -0,0 +1,135 @@ +package call_runtime + +import ( + "testing" + "time" +) + +func installWatchdogTimeouts(t *testing.T, ringing, connecting time.Duration) { + t.Helper() + previousRinging := ringingWatchdogTimeout + previousConnecting := connectingWatchdogTimeout + ringingWatchdogTimeout = ringing + connectingWatchdogTimeout = connecting + t.Cleanup(func() { + ringingWatchdogTimeout = previousRinging + connectingWatchdogTimeout = previousConnecting + }) +} + +func waitForTimeout(t *testing.T, timedOut <-chan string) string { + t.Helper() + select { + case callID := <-timedOut: + return callID + case <-time.After(2 * time.Second): + t.Fatal("watchdog callback did not run") + return "" + } +} + +func TestRuntimeWatchdogFailsStuckRingingCall(t *testing.T) { + installWatchdogTimeouts(t, 10*time.Millisecond, time.Second) + runtime := New("watchdog-instance", nil) + defer runtime.Close() + + timedOut := make(chan string, 1) + runtime.SetOnTimeout(func(instanceID, callID string) { + if instanceID != "watchdog-instance" { + t.Errorf("unexpected instance ID: %s", instanceID) + } + timedOut <- callID + }) + runtime.Transition("ringing-call", "peer", DirectionOutgoing, StateRinging, nil, "") + + if got := waitForTimeout(t, timedOut); got != "ringing-call" { + t.Fatalf("unexpected timed out call: %s", got) + } + call, ok := runtime.Call("ringing-call") + if !ok { + t.Fatal("timed out call was removed from public runtime") + } + if call.State != StateFailed || call.Error != "call ringing timed out" || call.EndReason != "timeout" { + t.Fatalf("unexpected timeout state: %+v", call) + } +} + +func TestRuntimeWatchdogFailsStuckConnectingCall(t *testing.T) { + installWatchdogTimeouts(t, time.Second, 10*time.Millisecond) + runtime := New("watchdog-instance", nil) + defer runtime.Close() + + timedOut := make(chan string, 1) + runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID }) + runtime.Transition("connecting-call", "peer", DirectionIncoming, StateConnecting, nil, "") + + if got := waitForTimeout(t, timedOut); got != "connecting-call" { + t.Fatalf("unexpected timed out call: %s", got) + } + call, _ := runtime.Call("connecting-call") + if call.State != StateFailed || call.Error != "call media negotiation timed out" { + t.Fatalf("unexpected connecting timeout state: %+v", call) + } +} + +func TestRuntimeWatchdogIsCancelledWhenMediaBecomesActive(t *testing.T) { + installWatchdogTimeouts(t, time.Second, 20*time.Millisecond) + runtime := New("watchdog-instance", nil) + defer runtime.Close() + + timedOut := make(chan string, 1) + runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID }) + runtime.Transition("active-call", "peer", DirectionOutgoing, StateConnecting, nil, "") + runtime.Transition("active-call", "", "", StateActive, nil, "") + + time.Sleep(60 * time.Millisecond) + select { + case callID := <-timedOut: + t.Fatalf("active call timed out: %s", callID) + default: + } + call, _ := runtime.Call("active-call") + if call.State != StateActive { + t.Fatalf("unexpected active call state: %+v", call) + } +} + +func TestRuntimeWatchdogIgnoresReplacedTimer(t *testing.T) { + installWatchdogTimeouts(t, 15*time.Millisecond, 60*time.Millisecond) + runtime := New("watchdog-instance", nil) + defer runtime.Close() + + timedOut := make(chan string, 2) + runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID }) + runtime.Transition("progressing-call", "peer", DirectionOutgoing, StateRinging, nil, "") + time.Sleep(5 * time.Millisecond) + runtime.Transition("progressing-call", "", "", StateConnecting, nil, "") + + // The old ringing timer must not fail the newer connecting state. + time.Sleep(25 * time.Millisecond) + select { + case callID := <-timedOut: + t.Fatalf("stale watchdog timer fired for %s", callID) + default: + } + + if got := waitForTimeout(t, timedOut); got != "progressing-call" { + t.Fatalf("unexpected connecting timeout: %s", got) + } +} + +func TestRuntimeCloseCancelsWatchdogs(t *testing.T) { + installWatchdogTimeouts(t, 20*time.Millisecond, 20*time.Millisecond) + runtime := New("watchdog-instance", nil) + timedOut := make(chan string, 1) + runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID }) + runtime.Transition("closed-call", "peer", DirectionOutgoing, StateRinging, nil, "") + runtime.Close() + + time.Sleep(60 * time.Millisecond) + select { + case callID := <-timedOut: + t.Fatalf("closed runtime watchdog fired for %s", callID) + default: + } +} From 807352c2ac2c94072af526c92941572fefbd6953 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:56:24 -0300 Subject: [PATCH 221/266] fix(call): close video child iteration --- pkg/call/runtime/runtime.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/call/runtime/runtime.go b/pkg/call/runtime/runtime.go index 5445cf4c..c65338c8 100644 --- a/pkg/call/runtime/runtime.go +++ b/pkg/call/runtime/runtime.go @@ -425,6 +425,7 @@ func callNodeContainsVideo(node *waBinary.Node) bool { if callNodeContainsVideo(&content[index]) { return true } + } case *waBinary.Node: return callNodeContainsVideo(content) } From 69c000eb28c8f1f794865bfcbfd12e784796f542 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:57:42 -0300 Subject: [PATCH 222/266] fix(call): keep watchdog deadline across duplicate events --- pkg/call/runtime/watchdog.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/pkg/call/runtime/watchdog.go b/pkg/call/runtime/watchdog.go index 5ca0a0c2..f977f636 100644 --- a/pkg/call/runtime/watchdog.go +++ b/pkg/call/runtime/watchdog.go @@ -6,7 +6,7 @@ import ( ) var ( - ringingWatchdogTimeout = 90 * time.Second + ringingWatchdogTimeout = 90 * time.Second connectingWatchdogTimeout = 45 * time.Second ) @@ -14,7 +14,6 @@ type runtimeWatchdogEntry struct { timer *time.Timer generation uint64 state State - updatedAt time.Time } type runtimeWatchdogState struct { @@ -62,18 +61,26 @@ func (r *Runtime) syncWatchdog(call Call) { state = &runtimeWatchdogState{entries: make(map[string]runtimeWatchdogEntry)} runtimeWatchdogs.states[r] = state } - if previous, ok := state.entries[call.ID]; ok && previous.timer != nil { - previous.timer.Stop() + if previous, ok := state.entries[call.ID]; ok { + // Duplicate CallAccept/CallTransport/CallOffer events must not extend the + // negotiation deadline indefinitely. Only a real state transition gets a + // new timer. + if previous.state == call.State { + runtimeWatchdogs.Unlock() + return + } + if previous.timer != nil { + previous.timer.Stop() + } } state.generation++ generation := state.generation entry := runtimeWatchdogEntry{ generation: generation, state: call.State, - updatedAt: call.UpdatedAt, } entry.timer = time.AfterFunc(timeout, func() { - r.expireWatchdog(call.ID, generation, call.State, call.UpdatedAt, reason) + r.expireWatchdog(call.ID, generation, call.State, reason) }) state.entries[call.ID] = entry runtimeWatchdogs.Unlock() @@ -90,7 +97,7 @@ func watchdogTimeout(state State) (time.Duration, string) { } } -func (r *Runtime) expireWatchdog(callID string, generation uint64, expectedState State, expectedUpdatedAt time.Time, reason string) { +func (r *Runtime) expireWatchdog(callID string, generation uint64, expectedState State, reason string) { if r == nil || callID == "" { return } @@ -112,7 +119,7 @@ func (r *Runtime) expireWatchdog(callID string, generation uint64, expectedState r.mu.Lock() call, ok := r.calls[callID] - if !ok || call.State != expectedState || !call.UpdatedAt.Equal(expectedUpdatedAt) { + if !ok || call.State != expectedState { r.mu.Unlock() return } From 7e67b06ddd404a9908309d0bf780972b176f9357 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:58:27 -0300 Subject: [PATCH 223/266] test(call): keep watchdog deadline across duplicate states --- pkg/call/runtime/watchdog_test.go | 38 +++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/pkg/call/runtime/watchdog_test.go b/pkg/call/runtime/watchdog_test.go index c1f9cf03..dab0f721 100644 --- a/pkg/call/runtime/watchdog_test.go +++ b/pkg/call/runtime/watchdog_test.go @@ -29,7 +29,7 @@ func waitForTimeout(t *testing.T, timedOut <-chan string) string { } func TestRuntimeWatchdogFailsStuckRingingCall(t *testing.T) { - installWatchdogTimeouts(t, 10*time.Millisecond, time.Second) + installWatchdogTimeouts(t, 20*time.Millisecond, time.Second) runtime := New("watchdog-instance", nil) defer runtime.Close() @@ -55,7 +55,7 @@ func TestRuntimeWatchdogFailsStuckRingingCall(t *testing.T) { } func TestRuntimeWatchdogFailsStuckConnectingCall(t *testing.T) { - installWatchdogTimeouts(t, time.Second, 10*time.Millisecond) + installWatchdogTimeouts(t, time.Second, 20*time.Millisecond) runtime := New("watchdog-instance", nil) defer runtime.Close() @@ -73,7 +73,7 @@ func TestRuntimeWatchdogFailsStuckConnectingCall(t *testing.T) { } func TestRuntimeWatchdogIsCancelledWhenMediaBecomesActive(t *testing.T) { - installWatchdogTimeouts(t, time.Second, 20*time.Millisecond) + installWatchdogTimeouts(t, time.Second, 30*time.Millisecond) runtime := New("watchdog-instance", nil) defer runtime.Close() @@ -82,7 +82,7 @@ func TestRuntimeWatchdogIsCancelledWhenMediaBecomesActive(t *testing.T) { runtime.Transition("active-call", "peer", DirectionOutgoing, StateConnecting, nil, "") runtime.Transition("active-call", "", "", StateActive, nil, "") - time.Sleep(60 * time.Millisecond) + time.Sleep(90 * time.Millisecond) select { case callID := <-timedOut: t.Fatalf("active call timed out: %s", callID) @@ -95,18 +95,18 @@ func TestRuntimeWatchdogIsCancelledWhenMediaBecomesActive(t *testing.T) { } func TestRuntimeWatchdogIgnoresReplacedTimer(t *testing.T) { - installWatchdogTimeouts(t, 15*time.Millisecond, 60*time.Millisecond) + installWatchdogTimeouts(t, 30*time.Millisecond, 80*time.Millisecond) runtime := New("watchdog-instance", nil) defer runtime.Close() timedOut := make(chan string, 2) runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID }) runtime.Transition("progressing-call", "peer", DirectionOutgoing, StateRinging, nil, "") - time.Sleep(5 * time.Millisecond) + time.Sleep(10 * time.Millisecond) runtime.Transition("progressing-call", "", "", StateConnecting, nil, "") // The old ringing timer must not fail the newer connecting state. - time.Sleep(25 * time.Millisecond) + time.Sleep(45 * time.Millisecond) select { case callID := <-timedOut: t.Fatalf("stale watchdog timer fired for %s", callID) @@ -118,15 +118,35 @@ func TestRuntimeWatchdogIgnoresReplacedTimer(t *testing.T) { } } +func TestRuntimeWatchdogDuplicateStateKeepsOriginalDeadline(t *testing.T) { + installWatchdogTimeouts(t, time.Second, time.Second) + runtime := New("watchdog-instance", nil) + defer runtime.Close() + + runtime.Transition("duplicate-call", "peer", DirectionOutgoing, StateConnecting, nil, "") + runtimeWatchdogs.Lock() + first := runtimeWatchdogs.states[runtime].entries["duplicate-call"] + runtimeWatchdogs.Unlock() + + runtime.Transition("duplicate-call", "peer", DirectionOutgoing, StateConnecting, nil, "") + runtimeWatchdogs.Lock() + second := runtimeWatchdogs.states[runtime].entries["duplicate-call"] + runtimeWatchdogs.Unlock() + + if first.generation != second.generation || first.timer != second.timer { + t.Fatalf("duplicate state replaced watchdog deadline: first=%d second=%d", first.generation, second.generation) + } +} + func TestRuntimeCloseCancelsWatchdogs(t *testing.T) { - installWatchdogTimeouts(t, 20*time.Millisecond, 20*time.Millisecond) + installWatchdogTimeouts(t, 30*time.Millisecond, 30*time.Millisecond) runtime := New("watchdog-instance", nil) timedOut := make(chan string, 1) runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID }) runtime.Transition("closed-call", "peer", DirectionOutgoing, StateRinging, nil, "") runtime.Close() - time.Sleep(60 * time.Millisecond) + time.Sleep(90 * time.Millisecond) select { case callID := <-timedOut: t.Fatalf("closed runtime watchdog fired for %s", callID) From f3a112034ecee05bd7c5aadf5579d23e96f9f2dd Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:41:40 -0300 Subject: [PATCH 224/266] chore(manager-v2): start new frontend foundation --- manager-v2/.keep | 1 + 1 file changed, 1 insertion(+) create mode 100644 manager-v2/.keep diff --git a/manager-v2/.keep b/manager-v2/.keep new file mode 100644 index 00000000..dfb88c57 --- /dev/null +++ b/manager-v2/.keep @@ -0,0 +1 @@ +foundation \ No newline at end of file From fa774d7abe5b6531d14794334344491295385556 Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:48:06 -0300 Subject: [PATCH 225/266] feat(manager-v2): add standalone React call workspace --- .github/workflows/manager-v2.yml | 39 +++ manager-v2/README.md | 41 ++++ manager-v2/index.html | 14 ++ manager-v2/package.json | 22 ++ manager-v2/src/api.ts | 145 +++++++++++ manager-v2/src/app.tsx | 397 +++++++++++++++++++++++++++++++ manager-v2/src/calls.ts | 118 +++++++++ manager-v2/src/main.tsx | 13 + manager-v2/src/pcm.ts | 350 +++++++++++++++++++++++++++ manager-v2/src/styles.css | 182 ++++++++++++++ manager-v2/tsconfig.json | 21 ++ manager-v2/vite.config.ts | 10 + 12 files changed, 1352 insertions(+) create mode 100644 .github/workflows/manager-v2.yml create mode 100644 manager-v2/README.md create mode 100644 manager-v2/index.html create mode 100644 manager-v2/package.json create mode 100644 manager-v2/src/api.ts create mode 100644 manager-v2/src/app.tsx create mode 100644 manager-v2/src/calls.ts create mode 100644 manager-v2/src/main.tsx create mode 100644 manager-v2/src/pcm.ts create mode 100644 manager-v2/src/styles.css create mode 100644 manager-v2/tsconfig.json create mode 100644 manager-v2/vite.config.ts diff --git a/.github/workflows/manager-v2.yml b/.github/workflows/manager-v2.yml new file mode 100644 index 00000000..de542ebd --- /dev/null +++ b/.github/workflows/manager-v2.yml @@ -0,0 +1,39 @@ +name: Manager V2 + +on: + pull_request: + paths: + - "manager-v2/**" + - ".github/workflows/manager-v2.yml" + push: + branches: + - feat/manager-v2-foundation + paths: + - "manager-v2/**" + - ".github/workflows/manager-v2.yml" + +permissions: + contents: read + +jobs: + build-manager-v2: + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: manager-v2 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22.12" + cache: npm + cache-dependency-path: manager-v2/package.json + - name: Install dependencies + run: npm install --no-audit --no-fund + - name: Typecheck + run: npm run typecheck + - name: Build + run: npm run build diff --git a/manager-v2/README.md b/manager-v2/README.md new file mode 100644 index 00000000..9b7f8155 --- /dev/null +++ b/manager-v2/README.md @@ -0,0 +1,41 @@ +# Evolution GO Manager V2 + +Novo frontend do Evolution GO, escrito do zero em React e TypeScript. + +## Princípios + +- nenhuma dependência do bundle compilado do Manager antigo; +- nenhuma cópia de componentes do AstraCalls/AGPL; +- APIs, autenticação e protocolo WebRTC pertencem ao Evolution GO; +- migração gradual: `/manager` permanece intacto enquanto o V2 evolui; +- telefonia é um módulo central, não um widget anexado posteriormente. + +## Primeira entrega + +- shell responsivo com navegação; +- configuração segura da URL e API key da instância; +- consulta periódica de `/call/status`; +- início, aceite, recusa e encerramento de chamadas; +- ponte WebRTC PCM `evolution-call-pcm` / `evcall.pcm.v1`; +- captura e reprodução com AudioWorklet; +- mute, estatísticas e diagnóstico local; +- áreas reservadas para instâncias, conversas, contatos e dashboard. + +## Desenvolvimento + +```bash +cd manager-v2 +npm install +npm run dev +``` + +O Vite inicia em `http://localhost:5173/manager-v2/`. Para testar chamadas contra uma API remota, informe a URL HTTPS e a API key no próprio Manager V2. + +## Build + +```bash +npm run typecheck +npm run build +``` + +O resultado é criado em `manager-v2/dist`. A publicação em `/manager-v2` será conectada ao servidor Go em uma etapa isolada, preservando `/manager` como fallback. diff --git a/manager-v2/index.html b/manager-v2/index.html new file mode 100644 index 00000000..5f9a5ebf --- /dev/null +++ b/manager-v2/index.html @@ -0,0 +1,14 @@ + + + + + + + + Evolution GO Manager V2 + + +
+ + + diff --git a/manager-v2/package.json b/manager-v2/package.json new file mode 100644 index 00000000..991319d5 --- /dev/null +++ b/manager-v2/package.json @@ -0,0 +1,22 @@ +{ + "name": "evolution-go-manager-v2", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.9.2", + "vite": "^8.1.5" + } +} diff --git a/manager-v2/src/api.ts b/manager-v2/src/api.ts new file mode 100644 index 00000000..8efa4894 --- /dev/null +++ b/manager-v2/src/api.ts @@ -0,0 +1,145 @@ +export interface EvolutionConnection { + baseUrl: string; + apiKey: string; + remember: boolean; +} + +export type CallDirection = "incoming" | "outgoing"; +export type CallState = "idle" | "ringing" | "connecting" | "active" | "ended" | "failed"; + +export interface EvolutionCall { + id: string; + peer: string; + direction: CallDirection; + state: CallState; + video?: boolean; + endReason?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface CallStatusSnapshot { + instanceId?: string; + connected: boolean; + calls: EvolutionCall[]; +} + +export interface WebRTCSessionResponse { + sessionId: string; + answer: RTCSessionDescriptionInit; +} + +const PERSISTENT_KEY = "evolution.managerV2.connection.v1"; +const SESSION_KEY = "evolution.managerV2.connection.session.v1"; + +export function loadConnection(): EvolutionConnection { + const parse = (value: string | null): Partial | null => { + if (!value) return null; + try { + return JSON.parse(value) as Partial; + } catch { + return null; + } + }; + const persistent = parse(localStorage.getItem(PERSISTENT_KEY)); + const temporary = parse(sessionStorage.getItem(SESSION_KEY)); + const value = persistent ?? temporary ?? {}; + return { + baseUrl: normalizeBaseUrl(value.baseUrl || window.location.origin), + apiKey: value.apiKey || "", + remember: Boolean(persistent), + }; +} + +export function saveConnection(connection: EvolutionConnection): void { + const normalized = { ...connection, baseUrl: normalizeBaseUrl(connection.baseUrl) }; + if (connection.remember) { + localStorage.setItem(PERSISTENT_KEY, JSON.stringify(normalized)); + sessionStorage.removeItem(SESSION_KEY); + } else { + sessionStorage.setItem(SESSION_KEY, JSON.stringify(normalized)); + localStorage.removeItem(PERSISTENT_KEY); + } +} + +export function normalizeBaseUrl(value: string): string { + return (value.trim() || window.location.origin).replace(/\/+$/, ""); +} + +export function normalizePhone(value: string): string { + return value.replace(/\D/g, ""); +} + +export function displayPhone(value: string): string { + return String(value || "").replace(/:\d+@/, "@").split("@")[0] || "Número não identificado"; +} + +export class EvolutionApi { + private readonly baseUrl: string; + private readonly apiKey: string; + + constructor(connection: EvolutionConnection) { + this.baseUrl = normalizeBaseUrl(connection.baseUrl); + this.apiKey = connection.apiKey.trim(); + } + + private async request(path: string, init: RequestInit = {}): Promise { + if (!this.apiKey) throw new Error("Informe a API key da instância"); + const headers = new Headers(init.headers); + headers.set("apikey", this.apiKey); + if (init.body !== undefined && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + const response = await fetch(`${this.baseUrl}${path}`, { ...init, headers }); + const body = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) { + const message = typeof body.error === "string" + ? body.error + : typeof body.message === "string" + ? body.message + : `HTTP ${response.status}`; + throw new Error(message); + } + return body as T; + } + + callStatus(): Promise { + return this.request("/call/status"); + } + + startCall(number: string): Promise { + return this.request("/call/start", { + method: "POST", + body: JSON.stringify({ number: normalizePhone(number), video: false }), + }); + } + + acceptCall(callId: string): Promise { + return this.request(`/call/${encodeURIComponent(callId)}/accept`, { method: "POST" }); + } + + rejectCall(call: EvolutionCall): Promise { + return this.request("/call/reject", { + method: "POST", + body: JSON.stringify({ number: call.peer, callCreator: call.peer, callId: call.id }), + }); + } + + terminateCall(callId: string): Promise { + return this.request(`/call/${encodeURIComponent(callId)}`, { method: "DELETE" }); + } + + createWebRTC(callId: string, offer: RTCSessionDescriptionInit): Promise { + return this.request(`/call/${encodeURIComponent(callId)}/webrtc`, { + method: "POST", + body: JSON.stringify({ offer }), + }); + } + + closeWebRTC(callId: string, sessionId: string): Promise { + return this.request( + `/call/${encodeURIComponent(callId)}/webrtc/${encodeURIComponent(sessionId)}`, + { method: "DELETE" }, + ); + } +} diff --git a/manager-v2/src/app.tsx b/manager-v2/src/app.tsx new file mode 100644 index 00000000..07e798a0 --- /dev/null +++ b/manager-v2/src/app.tsx @@ -0,0 +1,397 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + EvolutionApi, + displayPhone, + loadConnection, + normalizeBaseUrl, + normalizePhone, + saveConnection, + type EvolutionCall, + type EvolutionConnection, +} from "./api"; +import { useCallDesk } from "./calls"; +import { EvolutionPcmBridge, type MediaStats, type MediaStatus } from "./pcm"; + +type View = "overview" | "calls" | "instances" | "messages" | "contacts" | "settings"; + +interface LogEntry { + id: number; + timestamp: string; + message: string; + details?: string; +} + +const NAV_ITEMS: Array<{ id: View; icon: string; label: string }> = [ + { id: "overview", icon: "⌂", label: "Visão geral" }, + { id: "calls", icon: "☎", label: "Chamadas" }, + { id: "instances", icon: "◫", label: "Instâncias" }, + { id: "messages", icon: "✉", label: "Conversas" }, + { id: "contacts", icon: "◎", label: "Contatos" }, + { id: "settings", icon: "⚙", label: "Configurações" }, +]; + +function stateLabel(state: EvolutionCall["state"]): string { + return { + idle: "Inativa", + ringing: "Chamando", + connecting: "Conectando", + active: "Ativa", + ended: "Encerrada", + failed: "Falhou", + }[state] || state; +} + +function ConnectionEditor({ + value, + onSave, +}: { + value: EvolutionConnection; + onSave: (connection: EvolutionConnection) => void; +}) { + const [draft, setDraft] = useState(value); + useEffect(() => setDraft(value), [value]); + return ( +
+
+
+ Conexão +

Vincular uma instância

+
+ +
+
+ + +
+ +
+ +
+
+ ); +} + +function EmptyModule({ title, description }: { title: string; description: string }) { + return ( +
+
+

{title}

+

{description}

+ Estrutura preparada para a próxima fase do Manager V2. +
+ ); +} + +function CallWorkspace({ api }: { api: EvolutionApi | null }) { + const desk = useCallDesk(api); + const [number, setNumber] = useState(""); + const [mediaStatus, setMediaStatus] = useState("idle"); + const [stats, setStats] = useState({ sent: 0, received: 0, dropped: 0 }); + const [muted, setMuted] = useState(false); + const [logs, setLogs] = useState([]); + const [autoConnectId, setAutoConnectId] = useState(""); + const bridgeRef = useRef(null); + + const log = (message: string, details?: unknown) => { + setLogs((current) => [...current.slice(-119), { + id: Date.now() + Math.random(), + timestamp: new Date().toLocaleTimeString(), + message, + details: details === undefined ? undefined : typeof details === "string" ? details : JSON.stringify(details), + }]); + }; + + useEffect(() => { + if (!api) { + void bridgeRef.current?.disconnect(false); + bridgeRef.current = null; + return; + } + const bridge = new EvolutionPcmBridge(api, { + onStatus: setMediaStatus, + onStats: setStats, + onLog: log, + }); + bridgeRef.current = bridge; + return () => { + void bridge.disconnect(); + bridgeRef.current = null; + }; + }, [api]); + + useEffect(() => { + const selected = desk.selectedCall; + const activeMediaCall = bridgeRef.current?.activeCallId; + if (activeMediaCall) { + const current = desk.snapshot.calls.find((call) => call.id === activeMediaCall); + if (!current || ["ended", "failed"].includes(current.state)) { + void bridgeRef.current?.disconnect(false); + } + } + if (selected?.id === autoConnectId && selected.state === "active" && mediaStatus === "idle") { + setAutoConnectId(""); + void bridgeRef.current?.connect(selected.id).catch((cause) => { + log("Conexão automática do áudio falhou", cause instanceof Error ? cause.message : cause); + }); + } + }, [autoConnectId, desk.selectedCall, desk.snapshot.calls, mediaStatus]); + + const selected = desk.selectedCall; + const incoming = desk.snapshot.calls.filter((call) => call.direction === "incoming" && call.state === "ringing").length; + const live = desk.snapshot.calls.filter((call) => !["ended", "failed"].includes(call.state)).length; + + const beginCall = async () => { + const normalized = normalizePhone(number); + if (normalized.length < 8 || normalized.length > 20) { + log("Número inválido", "Informe o número completo com DDI"); + return; + } + try { + const call = await desk.start(normalized); + setNumber(normalized); + setAutoConnectId(call.id); + log("Chamada iniciada", { callId: call.id, peer: call.peer }); + } catch { + // The hook exposes the error in the workspace. + } + }; + + const connectAudio = async () => { + if (!selected || selected.state !== "active") return; + try { + await bridgeRef.current?.connect(selected.id); + } catch (cause) { + log("Falha ao conectar áudio", cause instanceof Error ? cause.message : cause); + } + }; + + const terminate = async (call: EvolutionCall) => { + if (bridgeRef.current?.activeCallId === call.id) await bridgeRef.current.disconnect(); + await desk.terminate(call).catch(() => undefined); + log("Chamada encerrada", call.id); + }; + + return ( +
+
+
+
+ Central de voz +

Chamadas WhatsApp no navegador

+

Discagem, chamadas recebidas e mídia WebRTC em uma única área de trabalho.

+
+
+
{live}em andamento
+
{incoming}tocando
+
{stats.received}frames recebidos
+
+
+ +
+
+
+ Nova chamada +

Discador

+
+ + {desk.snapshot.connected ? "WhatsApp conectado" : "WhatsApp desconectado"} + +
+
+
+55
+ setNumber(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void beginCall(); + }} + /> + +
+ {desk.error &&
{desk.error}
} +
+ + {selected ? ( +
+
{displayPhone(selected.peer).slice(-2)}
+
+ {selected.direction === "incoming" ? "Chamada recebida" : "Chamada realizada"} +

{displayPhone(selected.peer)}

+
+ {stateLabel(selected.state)} + ID {selected.id} +
+
+ Áudio: {mediaStatus} + ↑ {stats.sent} + ↓ {stats.received} + Descartados {stats.dropped} +
+
+
+ {selected.direction === "incoming" && selected.state === "ringing" && ( + <> + + + + )} + {selected.state === "active" && mediaStatus === "idle" && ( + + )} + {mediaStatus === "connected" && ( + + )} + {!(["ended", "failed"] as string[]).includes(selected.state) && ( + + )} +
+
+ ) : ( +
+
+
Nenhuma chamada selecionada

A central está pronta

Inicie uma chamada ou aguarde uma ligação recebida.

+
+ )} + +
+
+
Tempo real

Chamadas da instância

+ +
+
+ {desk.snapshot.calls.length === 0 ? ( +
Nenhuma chamada registrada nesta sessão.
+ ) : [...desk.snapshot.calls].reverse().map((call) => ( + + ))} +
+
+
+ + +
+ ); +} + +export function App() { + const [view, setView] = useState("calls"); + const [connection, setConnection] = useState(loadConnection); + const api = useMemo(() => connection.apiKey ? new EvolutionApi(connection) : null, [connection]); + + const updateConnection = (next: EvolutionConnection) => { + saveConnection(next); + setConnection(next); + setView("calls"); + }; + + return ( +
+ + +
+
+
Manager V2 /{NAV_ITEMS.find((item) => item.id === view)?.label}
+
+ {connection.apiKey ? "API configurada" : "Configuração necessária"} + +
+
+
+ {view === "calls" && } + {view === "settings" && } + {view === "overview" && } + {view === "instances" && } + {view === "messages" && } + {view === "contacts" && } + {!connection.apiKey && view !== "settings" && ( +
+ )} +
+
+
+ ); +} diff --git a/manager-v2/src/calls.ts b/manager-v2/src/calls.ts new file mode 100644 index 00000000..9b21c337 --- /dev/null +++ b/manager-v2/src/calls.ts @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { EvolutionApi, EvolutionCall, CallStatusSnapshot } from "./api"; + +const POLL_INTERVAL_MS = 1800; + +export interface CallDeskState { + snapshot: CallStatusSnapshot; + selectedCall: EvolutionCall | null; + selectedCallId: string; + loading: boolean; + error: string; + setSelectedCallId: (callId: string) => void; + refresh: (quiet?: boolean) => Promise; + start: (number: string) => Promise; + accept: (call: EvolutionCall) => Promise; + reject: (call: EvolutionCall) => Promise; + terminate: (call: EvolutionCall) => Promise; +} + +const EMPTY_SNAPSHOT: CallStatusSnapshot = { connected: false, calls: [] }; + +export function useCallDesk(api: EvolutionApi | null): CallDeskState { + const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT); + const [selectedCallId, setSelectedCallId] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const chooseCall = useCallback((next: CallStatusSnapshot, currentId: string): string => { + if (currentId && next.calls.some((call) => call.id === currentId)) return currentId; + const live = [...next.calls].reverse().find((call) => !["ended", "failed"].includes(call.state)); + return live?.id || next.calls.at(-1)?.id || ""; + }, []); + + const refresh = useCallback(async (quiet = false) => { + if (!api) { + setSnapshot(EMPTY_SNAPSHOT); + return; + } + if (!quiet) setLoading(true); + try { + const next = await api.callStatus(); + next.calls = Array.isArray(next.calls) ? next.calls : []; + setSnapshot(next); + setSelectedCallId((current) => chooseCall(next, current)); + setError(""); + } catch (cause) { + if (!quiet) setError(cause instanceof Error ? cause.message : "Falha ao consultar chamadas"); + } finally { + if (!quiet) setLoading(false); + } + }, [api, chooseCall]); + + useEffect(() => { + void refresh(false); + if (!api) return; + const timer = window.setInterval(() => void refresh(true), POLL_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [api, refresh]); + + const run = useCallback(async (operation: () => Promise): Promise => { + setLoading(true); + setError(""); + try { + return await operation(); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Operação não concluída"; + setError(message); + throw cause; + } finally { + setLoading(false); + } + }, []); + + const start = useCallback(async (number: string) => { + if (!api) throw new Error("Configure a conexão da instância"); + const call = await run(() => api.startCall(number)); + setSelectedCallId(call.id); + await refresh(true); + return call; + }, [api, refresh, run]); + + const accept = useCallback(async (call: EvolutionCall) => { + if (!api) throw new Error("Configure a conexão da instância"); + await run(() => api.acceptCall(call.id)); + await refresh(true); + }, [api, refresh, run]); + + const reject = useCallback(async (call: EvolutionCall) => { + if (!api) throw new Error("Configure a conexão da instância"); + await run(() => api.rejectCall(call)); + await refresh(true); + }, [api, refresh, run]); + + const terminate = useCallback(async (call: EvolutionCall) => { + if (!api) throw new Error("Configure a conexão da instância"); + await run(() => api.terminateCall(call.id)); + await refresh(true); + }, [api, refresh, run]); + + const selectedCall = useMemo( + () => snapshot.calls.find((call) => call.id === selectedCallId) ?? null, + [selectedCallId, snapshot.calls], + ); + + return { + snapshot, + selectedCall, + selectedCallId, + loading, + error, + setSelectedCallId, + refresh, + start, + accept, + reject, + terminate, + }; +} diff --git a/manager-v2/src/main.tsx b/manager-v2/src/main.tsx new file mode 100644 index 00000000..c1441442 --- /dev/null +++ b/manager-v2/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./app"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Manager V2 root element was not found"); + +createRoot(root).render( + + + , +); diff --git a/manager-v2/src/pcm.ts b/manager-v2/src/pcm.ts new file mode 100644 index 00000000..94fa4ff2 --- /dev/null +++ b/manager-v2/src/pcm.ts @@ -0,0 +1,350 @@ +import type { EvolutionApi } from "./api"; + +const DATA_CHANNEL_LABEL = "evolution-call-pcm"; +const DATA_CHANNEL_PROTOCOL = "evcall.pcm.v1"; +const PCM_RATE = 16000; +const PCM_FRAME_SAMPLES = 960; +const HEADER_BYTES = 16; +const MAX_BUFFERED_AMOUNT = 256 * 1024; + +export interface MediaStats { + sent: number; + received: number; + dropped: number; +} + +export type MediaStatus = "idle" | "connecting" | "connected" | "failed"; + +interface BridgeCallbacks { + onStatus: (status: MediaStatus) => void; + onStats: (stats: MediaStats) => void; + onLog: (message: string, details?: unknown) => void; +} + +class StreamingLinearResampler { + private readonly step: number; + private position = 0; + private carry = new Float32Array(0); + + constructor(inputRate: number, outputRate: number) { + this.step = inputRate / outputRate; + } + + push(input: Float32Array): Float32Array { + if (!input.length) return new Float32Array(0); + const data = new Float32Array(this.carry.length + input.length); + data.set(this.carry); + data.set(input, this.carry.length); + const output: number[] = []; + let position = this.position; + while (position + 1 < data.length) { + const left = Math.floor(position); + const fraction = position - left; + output.push(data[left] + (data[left + 1] - data[left]) * fraction); + position += this.step; + } + const consumed = Math.floor(position); + this.carry = data.slice(Math.min(consumed, data.length)); + this.position = position - consumed; + return Float32Array.from(output); + } +} + +function encodePCM(samples: Float32Array): ArrayBuffer { + const buffer = new ArrayBuffer(HEADER_BYTES + samples.length * 4); + const bytes = new Uint8Array(buffer); + bytes.set([0x45, 0x56, 0x50, 0x43]); + const view = new DataView(buffer); + view.setUint8(4, 1); + view.setUint8(5, 1); + view.setUint16(6, 0, true); + view.setUint32(8, PCM_RATE, true); + view.setUint32(12, samples.length, true); + samples.forEach((value, index) => { + const sample = Number.isFinite(value) ? Math.max(-1, Math.min(1, value)) : 0; + view.setFloat32(HEADER_BYTES + index * 4, sample, true); + }); + return buffer; +} + +function decodePCM(buffer: ArrayBuffer): Float32Array { + if (buffer.byteLength < HEADER_BYTES) throw new Error("Frame PCM truncado"); + const bytes = new Uint8Array(buffer, 0, 4); + if (bytes[0] !== 0x45 || bytes[1] !== 0x56 || bytes[2] !== 0x50 || bytes[3] !== 0x43) { + throw new Error("Cabeçalho PCM inválido"); + } + const view = new DataView(buffer); + if (view.getUint8(4) !== 1 || view.getUint8(5) !== 1 || view.getUint16(6, true) !== 0) { + throw new Error("Versão PCM incompatível"); + } + if (view.getUint32(8, true) !== PCM_RATE) throw new Error("Sample rate PCM incompatível"); + const count = view.getUint32(12, true); + if (!count || count > PCM_FRAME_SAMPLES * 4 || buffer.byteLength !== HEADER_BYTES + count * 4) { + throw new Error("Tamanho PCM inválido"); + } + const output = new Float32Array(count); + for (let index = 0; index < count; index++) { + output[index] = view.getFloat32(HEADER_BYTES + index * 4, true); + } + return output; +} + +async function installWorklet(context: AudioContext): Promise { + const source = ` + class EvolutionManagerV2PCM extends AudioWorkletProcessor { + constructor(options) { + super(); + this.mode = options.processorOptions.mode; + this.queue = []; + this.offset = 0; + this.port.onmessage = event => { + if (this.mode === 'playback' && event.data instanceof Float32Array) this.queue.push(event.data); + }; + } + process(inputs, outputs) { + if (this.mode === 'capture') { + const input = inputs[0] && inputs[0][0]; + if (input && input.length) this.port.postMessage(new Float32Array(input)); + } else { + const output = outputs[0] && outputs[0][0]; + if (output) { + output.fill(0); + let written = 0; + while (written < output.length && this.queue.length) { + const chunk = this.queue[0]; + const count = Math.min(output.length - written, chunk.length - this.offset); + output.set(chunk.subarray(this.offset, this.offset + count), written); + written += count; + this.offset += count; + if (this.offset >= chunk.length) { this.queue.shift(); this.offset = 0; } + } + } + } + return true; + } + } + registerProcessor('evolution-manager-v2-pcm', EvolutionManagerV2PCM); + `; + const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" })); + try { + await context.audioWorklet.addModule(url); + } finally { + URL.revokeObjectURL(url); + } +} + +async function gatherICE(connection: RTCPeerConnection): Promise { + if (connection.iceGatheringState === "complete") return; + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + connection.removeEventListener("icegatheringstatechange", listener); + reject(new Error("Timeout ao coletar candidatos ICE")); + }, 15000); + const listener = () => { + if (connection.iceGatheringState === "complete") { + window.clearTimeout(timeout); + connection.removeEventListener("icegatheringstatechange", listener); + resolve(); + } + }; + connection.addEventListener("icegatheringstatechange", listener); + }); +} + +export class EvolutionPcmBridge { + private peer: RTCPeerConnection | null = null; + private channel: RTCDataChannel | null = null; + private sessionId = ""; + private callId = ""; + private audioContext: AudioContext | null = null; + private microphone: MediaStream | null = null; + private captureSource: MediaStreamAudioSourceNode | null = null; + private captureNode: AudioWorkletNode | null = null; + private playbackNode: AudioWorkletNode | null = null; + private captureResampler: StreamingLinearResampler | null = null; + private playbackResampler: StreamingLinearResampler | null = null; + private pending = new Float32Array(0); + private muted = false; + private stats: MediaStats = { sent: 0, received: 0, dropped: 0 }; + + constructor( + private readonly api: EvolutionApi, + private readonly callbacks: BridgeCallbacks, + ) {} + + get activeCallId(): string { + return this.callId; + } + + get isMuted(): boolean { + return this.muted; + } + + setMuted(value: boolean): void { + this.muted = value; + this.callbacks.onLog(value ? "Microfone silenciado" : "Microfone ativado"); + } + + private publishStats(): void { + this.callbacks.onStats({ ...this.stats }); + } + + private appendCapture(samples: Float32Array): void { + const joined = new Float32Array(this.pending.length + samples.length); + joined.set(this.pending); + joined.set(samples, this.pending.length); + let offset = 0; + while (joined.length - offset >= PCM_FRAME_SAMPLES) { + const frame = joined.slice(offset, offset + PCM_FRAME_SAMPLES); + offset += PCM_FRAME_SAMPLES; + if (this.muted) continue; + if (this.channel?.readyState === "open" && this.channel.bufferedAmount <= MAX_BUFFERED_AMOUNT) { + this.channel.send(encodePCM(frame)); + this.stats.sent++; + } else { + this.stats.dropped++; + } + } + this.pending = joined.slice(offset); + this.publishStats(); + } + + private async startAudio(): Promise { + if (!window.AudioContext || !window.AudioWorkletNode) { + throw new Error("Este navegador não suporta AudioWorklet"); + } + this.audioContext = new AudioContext({ latencyHint: "interactive" }); + await installWorklet(this.audioContext); + await this.audioContext.resume(); + this.captureResampler = new StreamingLinearResampler(this.audioContext.sampleRate, PCM_RATE); + this.playbackResampler = new StreamingLinearResampler(PCM_RATE, this.audioContext.sampleRate); + + this.playbackNode = new AudioWorkletNode(this.audioContext, "evolution-manager-v2-pcm", { + numberOfInputs: 0, + numberOfOutputs: 1, + outputChannelCount: [1], + processorOptions: { mode: "playback" }, + }); + this.playbackNode.connect(this.audioContext.destination); + + this.microphone = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + video: false, + }); + this.captureSource = this.audioContext.createMediaStreamSource(this.microphone); + this.captureNode = new AudioWorkletNode(this.audioContext, "evolution-manager-v2-pcm", { + numberOfInputs: 1, + numberOfOutputs: 0, + processorOptions: { mode: "capture" }, + }); + this.captureNode.port.onmessage = (event: MessageEvent) => { + const resampled = this.captureResampler?.push(event.data) ?? new Float32Array(0); + this.appendCapture(resampled); + }; + this.captureSource.connect(this.captureNode); + this.callbacks.onLog("Microfone e reprodução iniciados", { sampleRate: this.audioContext.sampleRate }); + } + + async connect(callId: string): Promise { + if (!window.isSecureContext && location.hostname !== "localhost") { + throw new Error("O microfone exige HTTPS"); + } + if (this.peer) await this.disconnect(); + this.callbacks.onStatus("connecting"); + this.callId = callId; + this.stats = { sent: 0, received: 0, dropped: 0 }; + this.pending = new Float32Array(0); + this.publishStats(); + + const peer = new RTCPeerConnection({ iceServers: [] }); + const channel = peer.createDataChannel(DATA_CHANNEL_LABEL, { + ordered: true, + protocol: DATA_CHANNEL_PROTOCOL, + }); + channel.binaryType = "arraybuffer"; + channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT / 2; + this.peer = peer; + this.channel = channel; + + channel.onopen = () => { + void this.startAudio() + .then(() => this.callbacks.onStatus("connected")) + .catch(async (cause) => { + this.callbacks.onLog("Falha ao iniciar áudio", cause instanceof Error ? cause.message : cause); + this.callbacks.onStatus("failed"); + await this.disconnect(); + }); + }; + channel.onmessage = (event: MessageEvent) => { + try { + const pcm = decodePCM(event.data); + const playback = this.playbackResampler?.push(pcm) ?? new Float32Array(0); + if (playback.length) this.playbackNode?.port.postMessage(playback, [playback.buffer]); + this.stats.received++; + } catch (cause) { + this.stats.dropped++; + this.callbacks.onLog("Frame de áudio rejeitado", cause instanceof Error ? cause.message : cause); + } + this.publishStats(); + }; + channel.onerror = () => this.callbacks.onLog("Erro no DataChannel de áudio"); + channel.onclose = () => { + if (this.callId === callId) void this.disconnect(false); + }; + peer.onconnectionstatechange = () => { + this.callbacks.onLog("PeerConnection", peer.connectionState); + if (["failed", "closed"].includes(peer.connectionState)) void this.disconnect(false); + }; + + try { + await peer.setLocalDescription(await peer.createOffer()); + await gatherICE(peer); + if (!peer.localDescription) throw new Error("Oferta WebRTC não foi criada"); + const response = await this.api.createWebRTC(callId, { + type: "offer", + sdp: peer.localDescription.sdp, + }); + this.sessionId = response.sessionId; + await peer.setRemoteDescription(response.answer); + this.callbacks.onLog("Sessão WebRTC criada", { callId, sessionId: response.sessionId }); + } catch (cause) { + await this.disconnect(false); + this.callbacks.onStatus("failed"); + throw cause; + } + } + + async disconnect(notifyServer = true): Promise { + const callId = this.callId; + const sessionId = this.sessionId; + this.callId = ""; + this.sessionId = ""; + this.microphone?.getTracks().forEach((track) => track.stop()); + this.microphone = null; + this.captureSource?.disconnect(); + this.captureNode?.disconnect(); + this.playbackNode?.disconnect(); + this.captureSource = null; + this.captureNode = null; + this.playbackNode = null; + if (this.audioContext) await this.audioContext.close().catch(() => undefined); + this.audioContext = null; + this.channel?.close(); + this.peer?.close(); + this.channel = null; + this.peer = null; + this.captureResampler = null; + this.playbackResampler = null; + this.pending = new Float32Array(0); + this.callbacks.onStatus("idle"); + if (notifyServer && callId && sessionId) { + await this.api.closeWebRTC(callId, sessionId).catch(() => undefined); + } + if (callId) this.callbacks.onLog("Áudio desconectado", { callId, ...this.stats }); + } +} diff --git a/manager-v2/src/styles.css b/manager-v2/src/styles.css new file mode 100644 index 00000000..220d1edd --- /dev/null +++ b/manager-v2/src/styles.css @@ -0,0 +1,182 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #e9f2ef; + background: #07110f; + font-synthesis: none; + text-rendering: optimizeLegibility; + --bg: #07110f; + --panel: #0c1916; + --panel-2: #10211d; + --panel-3: #152a25; + --line: rgba(155, 199, 186, 0.14); + --muted: #83a49b; + --text: #e9f2ef; + --accent: #28d17c; + --accent-2: #75f0ac; + --danger: #ff5c6c; + --warning: #f6c85f; + color-scheme: dark; +} + +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 70% -20%, rgba(40, 209, 124, .14), transparent 36%), var(--bg); } +button, input { font: inherit; } +button { color: inherit; } + +.app-shell { min-height: 100vh; display: grid; grid-template-columns: 248px minmax(0, 1fr); } +.sidebar { position: sticky; top: 0; height: 100vh; padding: 24px 18px; border-right: 1px solid var(--line); background: rgba(7, 17, 15, .94); display: flex; flex-direction: column; backdrop-filter: blur(22px); } +.brand { display: flex; gap: 12px; align-items: center; padding: 4px 10px 30px; } +.brand-mark { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 13px; background: linear-gradient(145deg, var(--accent), #137a4c); color: #02130a; font-size: 23px; font-weight: 900; box-shadow: 0 12px 28px rgba(40, 209, 124, .22); } +.brand strong, .brand small { display: block; } +.brand small { color: var(--muted); margin-top: 2px; } +.sidebar nav { display: grid; gap: 7px; } +.sidebar nav button { border: 0; background: transparent; color: #9bb2ab; padding: 12px 14px; border-radius: 12px; display: flex; align-items: center; gap: 12px; cursor: pointer; text-align: left; } +.sidebar nav button span { width: 22px; text-align: center; font-size: 17px; } +.sidebar nav button:hover { background: rgba(255,255,255,.04); color: var(--text); } +.sidebar nav button.active { background: linear-gradient(90deg, rgba(40,209,124,.18), rgba(40,209,124,.06)); color: var(--accent-2); box-shadow: inset 3px 0 var(--accent); } +.sidebar-foot { margin-top: auto; padding: 15px 12px; border: 1px solid var(--line); border-radius: 14px; background: rgba(255,255,255,.025); display: flex; gap: 10px; align-items: center; min-width: 0; } +.sidebar-foot strong, .sidebar-foot small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sidebar-foot small { color: var(--muted); margin-top: 3px; font-size: 11px; } +.status-dot { width: 9px; height: 9px; border-radius: 99px; background: #53635e; box-shadow: 0 0 0 4px rgba(83,99,94,.14); flex: 0 0 auto; } +.status-dot.online { background: var(--accent); box-shadow: 0 0 0 4px rgba(40,209,124,.13), 0 0 18px rgba(40,209,124,.45); } + +main { min-width: 0; } +.topbar { height: 74px; padding: 0 30px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(7,17,15,.72); backdrop-filter: blur(18px); position: sticky; top: 0; z-index: 20; } +.breadcrumb { color: var(--muted); margin-right: 7px; } +.top-actions { display: flex; align-items: center; gap: 12px; } +.profile-button { border: 1px solid var(--line); width: 38px; height: 38px; border-radius: 12px; background: var(--panel-2); cursor: pointer; font-weight: 800; } +.content { padding: 28px; max-width: 1600px; margin: 0 auto; position: relative; } + +.card { border: 1px solid var(--line); background: linear-gradient(145deg, rgba(16,33,29,.96), rgba(9,23,20,.96)); border-radius: 19px; box-shadow: 0 18px 50px rgba(0,0,0,.18); } +.call-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 22px; align-items: start; } +.call-main, .call-aside { display: grid; gap: 18px; } +.hero { padding: 28px; display: flex; justify-content: space-between; align-items: center; overflow: hidden; position: relative; } +.hero::after { content: ""; position: absolute; width: 260px; height: 260px; right: -70px; top: -130px; border-radius: 50%; background: rgba(40,209,124,.09); border: 1px solid rgba(40,209,124,.18); } +.hero h1 { margin: 5px 0 8px; font-size: clamp(25px, 3vw, 38px); line-height: 1.08; letter-spacing: -.04em; } +.hero p { margin: 0; color: var(--muted); max-width: 600px; } +.eyebrow { color: var(--accent-2); font-size: 11px; font-weight: 800; letter-spacing: .15em; text-transform: uppercase; } +.hero-metrics { display: flex; gap: 12px; position: relative; z-index: 1; } +.hero-metrics div { min-width: 96px; padding: 14px; border-radius: 15px; background: rgba(0,0,0,.18); border: 1px solid var(--line); } +.hero-metrics strong, .hero-metrics span { display: block; } +.hero-metrics strong { font-size: 24px; } +.hero-metrics span { color: var(--muted); font-size: 11px; margin-top: 3px; } + +.dialer-card, .active-call-card, .call-history, .diagnostic-card, .quality-card, .connection-card { padding: 22px; } +.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 18px; } +.section-heading h2 { margin: 3px 0 0; font-size: 18px; } +.connection-pill { padding: 7px 10px; border-radius: 99px; font-size: 11px; border: 1px solid var(--line); color: var(--muted); } +.connection-pill.connected { color: var(--accent-2); background: rgba(40,209,124,.08); border-color: rgba(40,209,124,.22); } +.connection-pill.disconnected { color: #ff9aa5; background: rgba(255,92,108,.07); } +.dial-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; } +.country-code, input { min-height: 48px; border: 1px solid var(--line); border-radius: 13px; background: rgba(2,12,9,.55); color: var(--text); } +.country-code { display: grid; place-items: center; padding: 0 15px; color: var(--muted); } +input { width: 100%; padding: 0 15px; outline: none; } +input:focus { border-color: rgba(40,209,124,.7); box-shadow: 0 0 0 3px rgba(40,209,124,.1); } +.button { border: 1px solid transparent; border-radius: 13px; padding: 0 18px; min-height: 44px; cursor: pointer; font-weight: 750; } +.button:disabled { opacity: .45; cursor: not-allowed; } +.button.primary, .call-button { background: linear-gradient(145deg, var(--accent), #1ca965); color: #03150b; box-shadow: 0 10px 26px rgba(40,209,124,.18); } +.button.secondary { background: var(--panel-3); border-color: var(--line); } +.call-button { min-height: 48px; min-width: 120px; } +.alert { margin-top: 12px; padding: 10px 13px; border-radius: 11px; font-size: 13px; } +.alert.error { color: #ffb2ba; background: rgba(255,92,108,.08); border: 1px solid rgba(255,92,108,.2); } + +.active-call-card { display: flex; align-items: center; gap: 18px; min-height: 150px; } +.call-avatar { width: 70px; height: 70px; border-radius: 22px; display: grid; place-items: center; background: linear-gradient(145deg, rgba(40,209,124,.22), rgba(40,209,124,.07)); border: 1px solid rgba(40,209,124,.24); color: var(--accent-2); font-size: 23px; font-weight: 900; } +.active-call-content { min-width: 0; flex: 1; } +.active-call-content h2 { margin: 4px 0 8px; font-size: 25px; } +.call-meta, .media-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 14px; color: var(--muted); font-size: 12px; } +.media-strip { margin-top: 13px; padding-top: 12px; border-top: 1px solid var(--line); } +.call-actions { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; justify-content: flex-end; } +.round-action { width: 48px; height: 48px; border: 0; border-radius: 16px; cursor: pointer; font-size: 21px; font-weight: 800; } +.round-action.accept { background: var(--accent); color: #04160c; } +.round-action.danger { background: var(--danger); color: white; } +.placeholder-call p { color: var(--muted); margin: 6px 0 0; } + +.state-badge { display: inline-flex; align-items: center; width: fit-content; padding: 5px 9px; border-radius: 99px; font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; background: rgba(131,164,155,.12); color: #a7beb7; } +.state-active { background: rgba(40,209,124,.12); color: var(--accent-2); } +.state-ringing { background: rgba(246,200,95,.12); color: var(--warning); } +.state-connecting { background: rgba(83,159,255,.12); color: #8cbcff; } +.state-ended, .state-failed { background: rgba(255,92,108,.1); color: #ff9aa5; } +.icon-button { width: 38px; height: 38px; border-radius: 11px; border: 1px solid var(--line); background: var(--panel-3); cursor: pointer; } +.call-table { display: grid; gap: 7px; } +.call-row { width: 100%; display: grid; grid-template-columns: 42px minmax(180px, 1fr) 110px 100px; gap: 12px; align-items: center; padding: 12px; border: 1px solid transparent; border-radius: 13px; background: transparent; cursor: pointer; text-align: left; } +.call-row:hover, .call-row.selected { background: rgba(255,255,255,.027); border-color: var(--line); } +.direction-icon { width: 34px; height: 34px; border-radius: 11px; display: grid; place-items: center; background: rgba(40,209,124,.1); color: var(--accent-2); } +.direction-icon.incoming { background: rgba(83,159,255,.1); color: #8cbcff; } +.call-person { min-width: 0; } +.call-person strong, .call-person small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.call-person small { color: var(--muted); font-size: 10px; margin-top: 3px; } +.table-empty { padding: 26px; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 13px; } + +.diagnostic-card { min-height: 430px; } +.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 15px var(--accent); } +.log-list { max-height: 350px; overflow: auto; display: grid; gap: 9px; padding-right: 4px; } +.log-list > p { color: var(--muted); } +.log-entry { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 4px 9px; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: rgba(0,0,0,.13); } +.log-entry time { color: var(--muted); font-size: 10px; grid-row: 1 / 3; } +.log-entry strong { font-size: 12px; } +.log-entry span { color: var(--muted); font-size: 10px; overflow-wrap: anywhere; } +.quality-card h2 { margin: 5px 0 12px; } +.quality-card p { color: var(--muted); font-size: 13px; line-height: 1.5; } +.quality-bars { display: flex; align-items: flex-end; height: 48px; gap: 7px; } +.quality-bars i { width: 11px; height: 12px; border-radius: 4px; background: #263c36; } +.quality-bars i:nth-child(2) { height: 22px; background: #33564b; } +.quality-bars i:nth-child(3) { height: 34px; } +.quality-bars i:nth-child(4) { height: 46px; } +.quality-bars i.active { background: var(--accent); box-shadow: 0 0 12px rgba(40,209,124,.25); } + +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.form-grid label > span { display: block; margin-bottom: 7px; color: var(--muted); font-size: 12px; } +.check-row { display: flex; align-items: center; gap: 9px; color: var(--muted); margin-top: 14px; font-size: 13px; } +.check-row input { width: auto; min-height: auto; } +.button-row { display: flex; justify-content: flex-end; margin-top: 18px; } +.empty-module { min-height: 430px; display: grid; place-items: center; align-content: center; text-align: center; padding: 40px; } +.empty-module h2 { margin: 13px 0 7px; } +.empty-module p { max-width: 520px; color: var(--muted); } +.empty-module span { color: #5f7d74; font-size: 12px; } +.empty-icon { width: 62px; height: 62px; border-radius: 20px; display: grid; place-items: center; border: 1px solid var(--line); background: var(--panel-2); color: var(--accent-2); font-size: 28px; } +.setup-overlay { position: absolute; inset: 0; z-index: 10; padding: 80px 28px; background: rgba(7,17,15,.78); backdrop-filter: blur(10px); } +.setup-overlay .connection-card { max-width: 760px; margin: 0 auto; } + +@media (max-width: 1120px) { + .call-layout { grid-template-columns: 1fr; } + .call-aside { grid-template-columns: 1fr 1fr; } + .diagnostic-card { min-height: 280px; } +} +@media (max-width: 820px) { + .app-shell { grid-template-columns: 76px minmax(0, 1fr); } + .sidebar { padding: 20px 10px; } + .brand { padding-inline: 7px; justify-content: center; } + .brand > div, .sidebar nav button:not(.active)::after, .sidebar nav button { font-size: 0; } + .sidebar nav button { justify-content: center; padding: 12px; } + .sidebar nav button span { font-size: 18px; } + .sidebar-foot div { display: none; } + .sidebar-foot { justify-content: center; } + .content { padding: 18px; } + .topbar { padding: 0 18px; } + .hero { align-items: flex-start; flex-direction: column; gap: 20px; } + .call-row { grid-template-columns: 42px minmax(130px, 1fr) 90px; } + .call-row > :last-child { display: none; } +} +@media (max-width: 600px) { + .app-shell { display: block; } + .sidebar { position: fixed; inset: auto 0 0; width: 100%; height: auto; padding: 8px; z-index: 50; border-right: 0; border-top: 1px solid var(--line); } + .brand, .sidebar-foot { display: none; } + .sidebar nav { display: flex; justify-content: space-around; } + .sidebar nav button { flex: 1; } + .sidebar nav button:nth-child(n+5) { display: none; } + main { padding-bottom: 72px; } + .topbar { height: 62px; } + .connection-pill { display: none; } + .content { padding: 12px; } + .hero, .dialer-card, .active-call-card, .call-history, .diagnostic-card, .quality-card, .connection-card { padding: 17px; border-radius: 16px; } + .hero-metrics { width: 100%; overflow-x: auto; } + .dial-row { grid-template-columns: 68px minmax(0,1fr); } + .call-button { grid-column: 1 / -1; } + .active-call-card { align-items: flex-start; flex-wrap: wrap; } + .call-actions { width: 100%; justify-content: flex-start; } + .call-aside { grid-template-columns: 1fr; } + .form-grid { grid-template-columns: 1fr; } + .call-row { grid-template-columns: 38px minmax(0,1fr); } + .call-row > :nth-child(n+3) { display: none; } +} diff --git a/manager-v2/tsconfig.json b/manager-v2/tsconfig.json new file mode 100644 index 00000000..02c957f9 --- /dev/null +++ b/manager-v2/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/manager-v2/vite.config.ts b/manager-v2/vite.config.ts new file mode 100644 index 00000000..86c9538d --- /dev/null +++ b/manager-v2/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "/manager-v2/", + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: true, + }, +}); From 9f46d742fa45a39270889c059c1a045e795c691e Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:02:40 -0300 Subject: [PATCH 226/266] feat(manager-v2): add messaging API client --- manager-v2/src/api.ts | 92 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/manager-v2/src/api.ts b/manager-v2/src/api.ts index 8efa4894..3e368d53 100644 --- a/manager-v2/src/api.ts +++ b/manager-v2/src/api.ts @@ -29,6 +29,41 @@ export interface WebRTCSessionResponse { answer: RTCSessionDescriptionInit; } +export interface EvolutionContact { + Jid: string; + Found: boolean; + FirstName: string; + FullName: string; + PushName: string; + BusinessName: string; +} + +export interface CheckedUser { + Query: string; + IsInWhatsapp: boolean; + JID: string; + RemoteJID: string; + LID?: string | null; + VerifiedName?: string; +} + +export interface MessageSendResult { + id?: string; + messageId?: string; + timestamp?: number | string; + [key: string]: unknown; +} + +interface ApiEnvelope { + message: string; + data: T; +} + +interface CheckUserCollection { + Users?: CheckedUser[]; + users?: CheckedUser[]; +} + const PERSISTENT_KEY = "evolution.managerV2.connection.v1"; const SESSION_KEY = "evolution.managerV2.connection.session.v1"; @@ -87,7 +122,8 @@ export class EvolutionApi { if (!this.apiKey) throw new Error("Informe a API key da instância"); const headers = new Headers(init.headers); headers.set("apikey", this.apiKey); - if (init.body !== undefined && !headers.has("Content-Type")) { + const isFormData = typeof FormData !== "undefined" && init.body instanceof FormData; + if (init.body !== undefined && !isFormData && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } const response = await fetch(`${this.baseUrl}${path}`, { ...init, headers }); @@ -142,4 +178,58 @@ export class EvolutionApi { { method: "DELETE" }, ); } + + async contacts(): Promise { + const response = await this.request>("/user/contacts"); + return Array.isArray(response.data) ? response.data : []; + } + + async checkUser(number: string): Promise { + const normalized = normalizePhone(number); + if (!normalized) return null; + const response = await this.request>("/user/check", { + method: "POST", + body: JSON.stringify({ number: [normalized], formatJid: false }), + }); + const users = response.data?.Users ?? response.data?.users ?? []; + return users.find((user) => user.IsInWhatsapp) ?? users[0] ?? null; + } + + async sendText(number: string, text: string): Promise { + const response = await this.request>("/send/text", { + method: "POST", + body: JSON.stringify({ + number, + text, + delay: 0, + mentionAll: false, + mentionedJid: [], + quoted: { messageId: "", participant: "" }, + }), + }); + return response.data ?? {}; + } + + async sendMedia(number: string, file: File, caption = ""): Promise { + const form = new FormData(); + form.set("number", number); + form.set("type", mediaTypeForFile(file)); + form.set("caption", caption); + form.set("filename", file.name); + form.set("delay", "0"); + form.set("mentionAll", "false"); + form.set("file", file, file.name); + const response = await this.request>("/send/media", { + method: "POST", + body: form, + }); + return response.data ?? {}; + } +} + +function mediaTypeForFile(file: File): "image" | "video" | "audio" | "document" { + if (file.type.startsWith("image/")) return "image"; + if (file.type.startsWith("video/")) return "video"; + if (file.type.startsWith("audio/")) return "audio"; + return "document"; } From e791fe9c85c78e57e76cd44bd9cb9f6cf712782e Mon Sep 17 00:00:00 2001 From: Jefferson Hipolito De Oliveira <149891602+sshturbo@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:03:56 -0300 Subject: [PATCH 227/266] feat(manager-v2): add messaging and contacts workspaces --- manager-v2/src/messaging.tsx | 488 +++++++++++++++++++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 manager-v2/src/messaging.tsx diff --git a/manager-v2/src/messaging.tsx b/manager-v2/src/messaging.tsx new file mode 100644 index 00000000..7a8e8e26 --- /dev/null +++ b/manager-v2/src/messaging.tsx @@ -0,0 +1,488 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + displayPhone, + normalizePhone, + type EvolutionApi, + type EvolutionContact, + type MessageSendResult, +} from "./api"; + +const MESSAGE_STORE_KEY = "evolution.managerV2.messages.session.v1"; +const MAX_LOCAL_MESSAGES = 500; + +type LocalMessageStatus = "sending" | "sent" | "failed"; +type LocalMessageKind = "text" | "media"; + +interface LocalMessage { + id: string; + recipient: string; + recipientKey: string; + text: string; + fileName?: string; + kind: LocalMessageKind; + status: LocalMessageStatus; + createdAt: string; + serverId?: string; + error?: string; +} + +interface ContactState { + contacts: EvolutionContact[]; + loading: boolean; + error: string; + refresh: () => Promise; +} + +function useContacts(api: EvolutionApi | null): ContactState { + const [contacts, setContacts] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const refresh = useCallback(async () => { + if (!api) { + setContacts([]); + return; + } + setLoading(true); + setError(""); + try { + const result = await api.contacts(); + setContacts([...result].sort((left, right) => contactName(left).localeCompare(contactName(right), "pt-BR"))); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Não foi possível carregar os contatos"); + } finally { + setLoading(false); + } + }, [api]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { contacts, loading, error, refresh }; +} + +function contactName(contact: EvolutionContact): string { + return contact.BusinessName || contact.FullName || contact.PushName || contact.FirstName || displayPhone(contact.Jid); +} + +function contactInitials(contact: EvolutionContact): string { + const parts = contactName(contact).trim().split(/\s+/).filter(Boolean); + return (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)?.[0] ?? ""}` : parts[0]?.slice(0, 2) || "WA").toUpperCase(); +} + +function recipientIdentity(value: string): string { + const visible = displayPhone(value); + return normalizePhone(visible) || visible.toLowerCase(); +} + +function loadMessages(): LocalMessage[] { + try { + const parsed = JSON.parse(sessionStorage.getItem(MESSAGE_STORE_KEY) || "[]") as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter((item): item is LocalMessage => { + if (!item || typeof item !== "object") return false; + const candidate = item as Partial; + return typeof candidate.id === "string" + && typeof candidate.recipient === "string" + && typeof candidate.recipientKey === "string" + && typeof candidate.text === "string" + && typeof candidate.createdAt === "string"; + }).slice(-MAX_LOCAL_MESSAGES); + } catch { + return []; + } +} + +function persistMessages(messages: LocalMessage[]): void { + sessionStorage.setItem(MESSAGE_STORE_KEY, JSON.stringify(messages.slice(-MAX_LOCAL_MESSAGES))); +} + +function localMessageId(): string { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function responseMessageId(result: MessageSendResult): string | undefined { + if (typeof result.id === "string" && result.id) return result.id; + if (typeof result.messageId === "string" && result.messageId) return result.messageId; + return undefined; +} + +function formatMessageTime(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "agora" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +async function resolveRecipient(api: EvolutionApi, value: string): Promise { + const trimmed = value.trim(); + if (trimmed.includes("@")) return trimmed; + const normalized = normalizePhone(trimmed); + if (normalized.length < 8 || normalized.length > 20) { + throw new Error("Informe um número completo com DDI"); + } + const user = await api.checkUser(normalized); + if (!user || !user.IsInWhatsapp) { + throw new Error("O número não foi encontrado no WhatsApp"); + } + return user.RemoteJID || user.JID || normalized; +} + +function ContactList({ + contacts, + selectedRecipient, + query, + onQuery, + onSelect, +}: { + contacts: EvolutionContact[]; + selectedRecipient: string; + query: string; + onQuery: (value: string) => void; + onSelect: (contact: EvolutionContact) => void; +}) { + const normalizedQuery = query.trim().toLocaleLowerCase("pt-BR"); + const visible = contacts.filter((contact) => { + if (!normalizedQuery) return true; + return `${contactName(contact)} ${contact.Jid}`.toLocaleLowerCase("pt-BR").includes(normalizedQuery); + }); + + return ( + <> +
+ + onQuery(event.target.value)} placeholder="Buscar contato" /> +
+
+ {visible.length === 0 ? ( +
Nenhum contato encontrado.
+ ) : visible.map((contact) => ( + + ))} +
+ + ); +} + +export function MessagingWorkspace({ + api, + initialRecipient, + onStartCall, +}: { + api: EvolutionApi | null; + initialRecipient?: string; + onStartCall?: () => void; +}) { + const contactState = useContacts(api); + const [recipient, setRecipient] = useState(initialRecipient || ""); + const [recipientDraft, setRecipientDraft] = useState(initialRecipient ? displayPhone(initialRecipient) : ""); + const [contactQuery, setContactQuery] = useState(""); + const [text, setText] = useState(""); + const [file, setFile] = useState(null); + const [fileInputKey, setFileInputKey] = useState(0); + const [messages, setMessages] = useState(loadMessages); + const [sending, setSending] = useState(false); + const [notice, setNotice] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => persistMessages(messages), [messages]); + useEffect(() => { + if (!initialRecipient) return; + setRecipient(initialRecipient); + setRecipientDraft(displayPhone(initialRecipient)); + }, [initialRecipient]); + + const selectedContact = useMemo( + () => contactState.contacts.find((contact) => recipientIdentity(contact.Jid) === recipientIdentity(recipient)), + [contactState.contacts, recipient], + ); + const conversation = useMemo( + () => messages.filter((message) => message.recipientKey === recipientIdentity(recipient)), + [messages, recipient], + ); + + const selectContact = (contact: EvolutionContact) => { + setRecipient(contact.Jid); + setRecipientDraft(displayPhone(contact.Jid)); + setError(""); + setNotice(""); + }; + + const openTypedRecipient = async () => { + if (!api) return; + setError(""); + setNotice("Verificando número…"); + try { + const resolved = await resolveRecipient(api, recipientDraft); + setRecipient(resolved); + setRecipientDraft(displayPhone(resolved)); + setNotice("Número validado no WhatsApp."); + } catch (cause) { + setNotice(""); + setError(cause instanceof Error ? cause.message : "Não foi possível validar o número"); + } + }; + + const send = async () => { + if (!api || sending) return; + if (!text.trim() && !file) { + setError("Digite uma mensagem ou escolha um arquivo"); + return; + } + setSending(true); + setError(""); + setNotice(""); + let target = recipient; + try { + if (!target) target = await resolveRecipient(api, recipientDraft); + else if (!target.includes("@")) target = await resolveRecipient(api, target); + setRecipient(target); + setRecipientDraft(displayPhone(target)); + + const optimistic: LocalMessage = { + id: localMessageId(), + recipient: target, + recipientKey: recipientIdentity(target), + text: text.trim() || `Arquivo: ${file?.name ?? "mídia"}`, + fileName: file?.name, + kind: file ? "media" : "text", + status: "sending", + createdAt: new Date().toISOString(), + }; + setMessages((current) => [...current, optimistic]); + + try { + const result = file + ? await api.sendMedia(target, file, text.trim()) + : await api.sendText(target, text.trim()); + setMessages((current) => current.map((message) => message.id === optimistic.id + ? { ...message, status: "sent", serverId: responseMessageId(result) } + : message)); + setText(""); + setFile(null); + setFileInputKey((current) => current + 1); + setNotice(file ? "Arquivo enviado com sucesso." : "Mensagem enviada com sucesso."); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Falha ao enviar mensagem"; + setMessages((current) => current.map((item) => item.id === optimistic.id + ? { ...item, status: "failed", error: message } + : item)); + throw cause; + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Falha ao enviar mensagem"); + } finally { + setSending(false); + } + }; + + const startCall = async () => { + if (!api) return; + setError(""); + try { + const target = recipient || await resolveRecipient(api, recipientDraft); + await api.startCall(displayPhone(target)); + setNotice("Chamada iniciada. Abrindo a central de voz…"); + onStartCall?.(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Não foi possível iniciar a chamada"); + } + }; + + const clearConversation = () => { + const identity = recipientIdentity(recipient); + setMessages((current) => current.filter((message) => message.recipientKey !== identity)); + }; + + return ( +
+ + +
+
+
+ {selectedContact ? contactInitials(selectedContact) : "WA"} +
+ Conversa +

{selectedContact ? contactName(selectedContact) : recipient ? displayPhone(recipient) : "Nova conversa"}

+ {recipient ? displayPhone(recipient) : "Escolha um contato ou informe um número"} +
+
+ +
+ + {!recipient && ( +
+ setRecipientDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void openTypedRecipient(); + }} + /> + +
+ )} + +
+ {!recipient ? ( +

Comece uma conversa

Selecione um contato ou valide um número com DDI.

+ ) : conversation.length === 0 ? ( +

Canal pronto

As mensagens enviadas nesta sessão aparecerão aqui.

+ ) : conversation.map((message) => ( +
+ {message.fileName && ▧ {message.fileName}} +

{message.text}

+
+ + {message.status === "sending" ? "Enviando…" : message.status === "sent" ? "Enviada ✓" : "Falhou"} +
+ {message.error && {message.error}} +
+ ))} +
+ +
+ {file && ( +
+ ▧ {file.name} + +
+ )} +
+ +