2026-10-18 16:34:43 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeAvailable: Edge needs only an endpoint, no key. The constructor
|
|
|
|
|
// bakes in the default endpoint, so an Edge built via NewEdge is always
|
|
|
|
|
// available; only the zero value (built directly, without an endpoint) is
|
|
|
|
|
// not.
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestEdgeAvailable(t *testing.T) {
|
|
|
|
|
if (&Edge{}).Available(context.Background()) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Available without endpoint (zero value) = true, want false")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
|
|
|
|
|
EnvEdgeEndpoint: "wss://edge.example.com",
|
|
|
|
|
})})
|
|
|
|
|
if !p.Available(context.Background()) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Available with endpoint = false, want true")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if !NewEdge(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Available with default endpoint = false, want true (edge is the key-free fallback)")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeConnectionFails (error-path mock): a guaranteed dead loopback
|
|
|
|
|
// port yields a clear error, immediately and without network.
|
|
|
|
|
func TestEdgeConnectionFails(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
|
|
|
|
|
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
|
|
|
|
|
})})
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err := p.Synthesize(context.Background(), "Hello", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("Synthesize against dead port: no error")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "edge") || !strings.Contains(err.Error(), "WebSocket") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("error = %v, want a clear edge/WebSocket error", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeInvalidScheme: a non-ws(s) endpoint is a clear error.
|
|
|
|
|
func TestEdgeInvalidScheme(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
|
|
|
|
|
EnvEdgeEndpoint: "http://127.0.0.1:1234/",
|
|
|
|
|
})})
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err := p.Synthesize(context.Background(), "Hello", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if err == nil || !strings.Contains(err.Error(), "ws://") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("error = %v, want scheme hint ws://", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeSSML: the SSML request contains the voice and the text stripped
|
|
|
|
|
// of control characters and XML-escaped; xml:lang is fixed to "en-US" as in
|
|
|
|
|
// the reference.
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestEdgeSSML(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
got := edgeSSML("de-DE-KatjaNeural", "AT&T <customer> „done“")
|
2026-10-18 16:34:43 +02:00
|
|
|
for _, want := range []string{
|
|
|
|
|
"voice name='de-DE-KatjaNeural'",
|
|
|
|
|
"xml:lang='en-US'",
|
2026-10-24 11:15:35 +02:00
|
|
|
"AT&T <customer>",
|
2026-10-18 16:34:43 +02:00
|
|
|
} {
|
|
|
|
|
if !strings.Contains(got, want) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("SSML = %q, want %q", got, want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(got, "AT&T") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("SSML contains unescaped &: %q", got)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeEscape: control characters (e.g. vertical tab from OCR texts) are
|
|
|
|
|
// replaced by spaces, & < > are escaped.
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestEdgeEscape(t *testing.T) {
|
|
|
|
|
in := "a\x0bb&c<d>\x08e"
|
|
|
|
|
if want := "a b&c<d> e"; edgeEscape(in) != want {
|
|
|
|
|
t.Errorf("edgeEscape(%q) = %q, want %q", in, edgeEscape(in), want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeSpeechConfigMsg: output format (mono mp3) and path are correct.
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestEdgeSpeechConfigMsg(t *testing.T) {
|
|
|
|
|
msg := edgeSpeechConfigMsg(time.Unix(0, 0).UTC())
|
|
|
|
|
if !strings.Contains(msg, "Path:speech.config") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("speech.config message without Path: %q", msg)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if !strings.Contains(msg, edgeOutputFormat) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("speech.config message without output format %q: %q", edgeOutputFormat, msg)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeRequestURL: query parameters (token, Sec-MS-GEC, version) are
|
|
|
|
|
// added; the token does not end up in the redacted URL output.
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestEdgeRequestURL(t *testing.T) {
|
|
|
|
|
p := &Edge{Endpoint: "wss://edge.example.com/path"}
|
|
|
|
|
raw, err := p.requestURL(time.Unix(1700000000, 0).UTC())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("requestURL: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for _, want := range []string{
|
|
|
|
|
"TrustedClientToken=" + edgeTrustedClientToken,
|
|
|
|
|
"Sec-MS-GEC=",
|
|
|
|
|
"Sec-MS-GEC-Version=" + edgeSecMSGECVersion,
|
|
|
|
|
} {
|
|
|
|
|
if !strings.Contains(raw, want) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("requestURL = %q, want %q", raw, want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if got := redactURL(raw); strings.Contains(got, edgeTrustedClientToken) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("redactURL leaks the token: %q", got)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeSecMSGEC: format "<16 hex>-<64 hex>" (upper case), 5-minute grid
|
|
|
|
|
// (stable within the window, different across the boundary) and exact
|
|
|
|
|
// reference vectors of the edge-tts DRM implementation (v2).
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestEdgeSecMSGEC(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Reference vectors: (unix seconds, expected token), generated with the
|
|
|
|
|
// Python reference (edge-tts src/edge_tts/drm.py, as of 2026-10).
|
2026-10-18 16:34:43 +02:00
|
|
|
vectors := []struct {
|
|
|
|
|
ts int64
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{1700000400, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"},
|
|
|
|
|
{1700000599, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"},
|
|
|
|
|
{1765800000, "B9B5447115B4FD1A-6BB36279F5B3D1B53F84F786AF05C81A86909ABE30E695496BBE88FFAE2E3E83"},
|
|
|
|
|
{0, "52BC7A17B50DFEC9-28C4394775C8751CD2F6859C030C06DB1489ACAB3B3A7651831FC26895177ED6"},
|
|
|
|
|
}
|
|
|
|
|
for _, v := range vectors {
|
|
|
|
|
if got := edgeSecMSGEC(time.Unix(v.ts, 0).UTC()); got != v.want {
|
|
|
|
|
t.Errorf("edgeSecMSGEC(%d) =\n %s\nwant\n %s", v.ts, got, v.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Window stability (base at window start, mod 300 == 0).
|
2026-10-18 16:34:43 +02:00
|
|
|
base := time.Unix(1700000400, 0).UTC()
|
|
|
|
|
token := edgeSecMSGEC(base)
|
|
|
|
|
if edgeSecMSGEC(base.Add(4*time.Minute)) != token {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Sec-MS-GEC differs within the 5-minute window")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if edgeSecMSGEC(base.Add(6*time.Minute)) == token {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Sec-MS-GEC does not change across the window boundary")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if len(token) != 16+1+64 || !strings.Contains(token, "-") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("token format = %q, want <16 hex>-<64 hex>", token)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeRequestID: 32 hex characters, unique.
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestEdgeRequestID(t *testing.T) {
|
|
|
|
|
a, b := edgeRequestID(), edgeRequestID()
|
|
|
|
|
if len(a) != 32 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("request ID length = %d, want 32", len(a))
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if _, err := hex.DecodeString(a); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("request ID is not hex: %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if a == b {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("request ID not unique")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeSplitBinary: binary frame: 2 bytes big-endian header length, then
|
|
|
|
|
// header (Path:audio), then payload.
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestEdgeSplitBinary(t *testing.T) {
|
|
|
|
|
msg := []byte{0, 12, 'P', 'a', 't', 'h', ':', 'a', 'u', 'd', 'i', 'o', '\r', '\n', 1, 2, 3}
|
|
|
|
|
header, payload, ok := splitEdgeBinary(msg)
|
|
|
|
|
if !ok || header != "Path:audio\r\n" || string(payload) != "\x01\x02\x03" {
|
|
|
|
|
t.Errorf("splitEdgeBinary = %q, %x, %v", header, payload, ok)
|
|
|
|
|
}
|
|
|
|
|
if _, _, ok := splitEdgeBinary([]byte{0, 99, 'x'}); ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("oversized header length should be rejected")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// fakeEdgeServer is a loopback fake of the Edge WSS protocol (no real
|
|
|
|
|
// network call; everything stays on 127.0.0.1). It checks the two client
|
|
|
|
|
// messages and answers per protocol: a binary audio frame with Path:audio,
|
|
|
|
|
// then turn.end. audio is the payload to deliver; closing closes the
|
|
|
|
|
// connection before turn.end (error path).
|
2026-10-18 16:34:43 +02:00
|
|
|
func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server, *fakeEdgeSeen) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
seen := &fakeEdgeSeen{}
|
|
|
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if got := r.URL.Query().Get("TrustedClientToken"); got != edgeTrustedClientToken {
|
|
|
|
|
t.Errorf("TrustedClientToken = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if r.Header.Get("Origin") != edgeOrigin {
|
|
|
|
|
t.Errorf("Origin = %q", r.Header.Get("Origin"))
|
|
|
|
|
}
|
|
|
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Errorf("Upgrade: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer conn.Close()
|
|
|
|
|
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if _, cfg, err := conn.ReadMessage(); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("reading speech.config: %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
} else {
|
|
|
|
|
seen.mu.Lock()
|
|
|
|
|
seen.config = string(cfg)
|
|
|
|
|
seen.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
if _, req, err := conn.ReadMessage(); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("reading ssml: %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
} else {
|
|
|
|
|
seen.mu.Lock()
|
|
|
|
|
seen.ssml = string(req)
|
|
|
|
|
seen.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
if closing {
|
2026-10-24 11:15:35 +02:00
|
|
|
return // close the connection without answering
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
header := "X-RequestId:dummy\r\nPath:audio\r\n"
|
|
|
|
|
frame := make([]byte, 0, 2+len(header)+len(audio))
|
|
|
|
|
frame = append(frame, byte(len(header)>>8), byte(len(header)))
|
|
|
|
|
frame = append(frame, header...)
|
|
|
|
|
frame = append(frame, audio...)
|
|
|
|
|
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte("X-RequestId:dummy\r\nPath:turn.end\r\n\r\n")); err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(ts.Close)
|
|
|
|
|
return ts, seen
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type fakeEdgeSeen struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
config string
|
|
|
|
|
ssml string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *fakeEdgeSeen) snapshot() (configMsg, ssml string) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
return s.config, s.ssml
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeLoopbackFakeServer: complete protocol run against the loopback
|
|
|
|
|
// fake (tested fakeably: handshake parameters, both client messages,
|
|
|
|
|
// binary frame parsing, turn.end termination).
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestEdgeLoopbackFakeServer(t *testing.T) {
|
|
|
|
|
wantAudio := []byte("\xff\xfb\x90\x00" + "edge-audio")
|
|
|
|
|
ts, seen := fakeEdgeServer(t, wantAudio, false)
|
|
|
|
|
|
|
|
|
|
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
|
|
|
|
|
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
|
|
|
|
|
})})
|
2026-10-24 11:15:35 +02:00
|
|
|
data, err := p.Synthesize(context.Background(), "Hello & done", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("Synthesize against loopback fake: %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if string(data) != string(wantAudio) {
|
|
|
|
|
t.Errorf("Synthesize = %x, want %x", data, wantAudio)
|
|
|
|
|
}
|
|
|
|
|
configMsg, ssml := seen.snapshot()
|
|
|
|
|
if !strings.Contains(configMsg, "Path:speech.config") {
|
|
|
|
|
t.Errorf("speech.config = %q", configMsg)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(ssml, "Path:ssml") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("ssml request without Path:ssml: %q", ssml)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if !strings.Contains(ssml, "voice name='de-DE-KatjaNeural'") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("ssml without default voice de: %q", ssml)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if !strings.Contains(ssml, "Hello & done") {
|
|
|
|
|
t.Errorf("ssml without escaped text: %q", ssml)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeLoopbackConnectionAbort: the server closes after the requests
|
|
|
|
|
// without audio → clear "protocol changed?" error.
|
|
|
|
|
func TestEdgeLoopbackConnectionAbort(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
ts, _ := fakeEdgeServer(t, nil, true)
|
|
|
|
|
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
|
|
|
|
|
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
|
|
|
|
|
})})
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err := p.Synthesize(context.Background(), "Hello", "de")
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "protocol") {
|
|
|
|
|
t.Errorf("error = %v, want protocol-change hint", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestEdgeIntegrationReal is the integration marker against the REAL
|
|
|
|
|
// Microsoft endpoint, skippable via testing.Short (free, but real network;
|
|
|
|
|
// standard test runs and -short skip it; one-off manual acceptance with
|
|
|
|
|
// TALK_TEST_REAL_EDGE=1).
|
|
|
|
|
func TestEdgeIntegrationReal(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
if testing.Short() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Skip("skipped (-short)")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if os.Getenv("TALK_TEST_REAL_EDGE") != "1" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Skip("skipped (real network); run with TALK_TEST_REAL_EDGE=1")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
p := NewEdge(ChainConfig{})
|
2026-10-24 11:15:35 +02:00
|
|
|
data, err := p.Synthesize(context.Background(), "Short speech test via Edge.", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("Synthesize against real endpoint: %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if len(data) == 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("Synthesize against real endpoint: 0 bytes")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Logf("Edge integration: %d bytes of audio", len(data))
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|