diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d03f71 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.24-bookworm + +RUN apt-get update && apt-get install -y \ + pkg-config \ + libopus-dev \ + libopusfile-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN go build -o ts-client ./cmd/client/main.go + +ENTRYPOINT ["./ts-client"] diff --git a/client.exe b/client.exe new file mode 100644 index 0000000..5b8e318 Binary files /dev/null and b/client.exe differ diff --git a/cmd/client/main.go b/cmd/client/main.go index d07b035..860683b 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "syscall" + "time" "go-ts/internal/client" ) @@ -15,6 +16,10 @@ func main() { nickname := flag.String("nickname", "GoCient", "Nickname") flag.Parse() + // Wait for server to start (Docker fix) + log.Println("Waiting 5 seconds for server to start...") + time.Sleep(5 * time.Second) + log.Printf("Starting TS3 Client...") log.Printf("Server: %s", *serverAddr) log.Printf("Nickname: %s", *nickname) diff --git a/docker-compose.yml b/docker-compose.yml index 3d2d727..0020aa5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,5 +19,12 @@ services: max-size: "10m" max-file: "3" + client: + build: . + depends_on: + - teamspeak + command: ["--server", "teamspeak:9987"] + + volumes: ts3-data: diff --git a/go-ts.exe b/go-ts.exe new file mode 100644 index 0000000..fca8b95 Binary files /dev/null and b/go-ts.exe differ diff --git a/go.mod b/go.mod index f06cfe5..c415065 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,9 @@ go 1.24.0 toolchain go1.24.11 -require filippo.io/edwards25519 v1.1.0 - require ( - github.com/Hiroko103/go-quicklz v0.0.0-20190115215310-59904abc50d0 // indirect - github.com/dgryski/go-quicklz v0.0.0-20151014073603-d7042a82d57e // indirect - golang.org/x/crypto v0.47.0 // indirect + filippo.io/edwards25519 v1.1.0 + github.com/dgryski/go-quicklz v0.0.0-20151014073603-d7042a82d57e ) + +require gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 diff --git a/go.sum b/go.sum index 3d4876f..822bcea 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/Hiroko103/go-quicklz v0.0.0-20190115215310-59904abc50d0 h1:6+dYUy8Dg9WdPkXnA3MtdAZq1ry1+pLrc+ZMCMi0MPU= -github.com/Hiroko103/go-quicklz v0.0.0-20190115215310-59904abc50d0/go.mod h1:qBfAulKipfG2mar83JHqv6ykKbgDXHbmTWPY5gvr2zw= github.com/dgryski/go-quicklz v0.0.0-20151014073603-d7042a82d57e h1:MhBotBstN1h/GeA7lx7xstbFB8avummjt+nzOi2cY7Y= github.com/dgryski/go-quicklz v0.0.0-20151014073603-d7042a82d57e/go.mod h1:XLmYwGWgVzMPLlMmcNcWt3b5ixRabPLstWnPVEDRhzc= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM= +gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g= diff --git a/internal/client/client.go b/internal/client/client.go index dda5be1..99fde2a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -6,6 +6,8 @@ import ( "go-ts/pkg/protocol" "go-ts/pkg/transport" + + "gopkg.in/hraban/opus.v2" ) type Channel struct { @@ -24,6 +26,7 @@ type Client struct { // Counters PacketIDCounterC2S uint16 + VoicePacketID uint16 // State Connected bool @@ -36,13 +39,19 @@ type Client struct { // Server Data Channels map[uint64]*Channel + + // Audio + VoiceDecoders map[uint16]*opus.Decoder // Map VID (sender ID) to decoder + VoiceEncoder *opus.Encoder // Encoder for outgoing audio } func NewClient(nickname string) *Client { return &Client{ Nickname: nickname, PacketIDCounterC2S: 1, + VoicePacketID: 1, Channels: make(map[uint64]*Channel), + VoiceDecoders: make(map[uint16]*opus.Decoder), } } diff --git a/internal/client/voice.go b/internal/client/voice.go index 54a39d5..1b73eee 100644 --- a/internal/client/voice.go +++ b/internal/client/voice.go @@ -5,34 +5,154 @@ import ( "log" "go-ts/pkg/protocol" + + "gopkg.in/hraban/opus.v2" +) + +const ( + // TeamSpeak Codecs + CodecOpusVoice = 4 + CodecOpusMusic = 5 ) func (c *Client) handleVoice(pkt *protocol.Packet) { + // Only process Opus packets // Parse Voice Header (Server -> Client) - // VID(2) + CID(2) + Codec(1) + Data - if len(pkt.Data) < 5 { + // VId(2) + CId(2) + Codec(1) + Data + + var data []byte = pkt.Data + + // Decrypt if Encrypted + if !pkt.Header.FlagUnencrypted() { + if c.Handshake == nil || len(c.Handshake.SharedIV) == 0 { + log.Println("Received encrypted voice packet but no SharedIV available. Dropping.") + return + } + + crypto := &protocol.CryptoState{ + SharedIV: c.Handshake.SharedIV, + SharedMac: c.Handshake.SharedMac, + GenerationID: 0, + } + // Server->Client = false + key, nonce := crypto.GenerateKeyNonce(&pkt.Header, false) + + // Meta for Server->Client: PID(2) + PT(1) = 3 bytes + meta := make([]byte, 3) + binary.BigEndian.PutUint16(meta[0:2], pkt.Header.PacketID) + meta[2] = pkt.Header.Type + + var err error + data, err = protocol.DecryptEAX(key, nonce, meta, pkt.Data, pkt.Header.MAC[:]) + if err != nil { + // Voice decryption failure is common if keys mismatch or packet loss affects counter? + // But EAX doesn't depend on counter state, only PID which is in header. + log.Printf("Voice decryption failed: %v", err) + return + } + } + + if len(data) < 6 { + // Ignore empty/too small packets (e.g. silence or end) return } - vid := binary.BigEndian.Uint16(pkt.Data[0:2]) - // cid := binary.BigEndian.Uint16(pkt.Data[2:4]) // Talking client ID (not needed for echo) - codec := pkt.Data[4] - voiceData := pkt.Data[5:] + // The first 2 bytes are the sequence ID, not the client ID for the decoder key. + // The client ID is at data[2:4] + // vid := binary.BigEndian.Uint16(data[0:2]) // Sequence ID + vid := binary.BigEndian.Uint16(data[2:4]) // Talking Client ID (CORRECT KEY) + codec := data[4] + voiceData := data[5:] - log.Printf("Voice Packet received. VID=%d, Codec=%d, Size=%d", vid, codec, len(voiceData)) + // Only process Opus packets + if codec != CodecOpusVoice && codec != CodecOpusMusic { + log.Printf("Received non-Opus voice packet (Codec=%d). Ignoring echo.", codec) + return + } - // Build echo packet (Client -> Server) - // Format: VID(2) + Codec(1) + Data - echoData := make([]byte, 2+1+len(voiceData)) - binary.BigEndian.PutUint16(echoData[0:2], vid) + channels := 1 + if codec == CodecOpusMusic { + channels = 2 + } + + // 1. Get or Create Decoder for this VID + decoder, ok := c.VoiceDecoders[vid] + if !ok { + var err error + decoder, err = opus.NewDecoder(48000, channels) + if err != nil { + log.Printf("Failed to create Opus decoder: %v", err) + return + } + c.VoiceDecoders[vid] = decoder + } + + // 2. Decode Opus to PCM + // Max frame size for 120ms at 48kHz is 5760 samples + pcm := make([]int16, 5760*channels) + n, err := decoder.Decode(voiceData, pcm) + if err != nil { + log.Printf("Opus decode error: %v", err) + return + } + pcm = pcm[:n*channels] + + if n != 960 { + log.Printf("WARNING: Unusual Opus frame size: %d samples (expected 960 for 20ms)", n) + } + + // 3. Process PCM: Reduce Volume (divide by 4) + for i := range pcm { + pcm[i] = pcm[i] / 4 + } + + // 4. Get or Create Encoder + if c.VoiceEncoder == nil { + var err error + app := opus.AppVoIP + if channels == 2 { + app = opus.AppAudio + } + encoder, err := opus.NewEncoder(48000, channels, app) + if err != nil { + log.Printf("Failed to create Opus encoder: %v", err) + return + } + + // Optimize Quality + encoder.SetBitrate(64000) // 64 kbps (High Quality for Voice) + encoder.SetComplexity(10) // Max Complexity (Best Quality) + + c.VoiceEncoder = encoder + } + + // 5. Encode PCM to Opus + encoded := make([]byte, 1024) + nEnc, err := c.VoiceEncoder.Encode(pcm, encoded) + if err != nil { + log.Printf("Opus encode error: %v", err) + return + } + encoded = encoded[:nEnc] + + // log.Printf("Voice Processed (CGO): VID=%d, In=%d bytes, PCM=%d samples, Out=%d bytes", vid, len(voiceData), n, nEnc) + + // 6. Build echo packet (Client -> Server) + // Payload format: [VId(2)] [Codec(1)] [Data...] + echoData := make([]byte, 2+1+len(encoded)) + + c.VoicePacketID++ // Increment counter before using it + + // Correctly set VId in Payload to be the Sequence Number (not ClientID) + binary.BigEndian.PutUint16(echoData[0:2], c.VoicePacketID) echoData[2] = codec - copy(echoData[3:], voiceData) + copy(echoData[3:], encoded) echoPkt := protocol.NewPacket(protocol.PacketTypeVoice, echoData) - echoPkt.Header.PacketID = pkt.Header.PacketID // Use same ID for voice + echoPkt.Header.PacketID = c.VoicePacketID echoPkt.Header.ClientID = c.ClientID - // Encrypt voice packet with SharedSecret + // Encrypt voice packet if c.Handshake != nil && len(c.Handshake.SharedIV) > 0 { crypto := &protocol.CryptoState{ SharedIV: c.Handshake.SharedIV, @@ -41,7 +161,6 @@ func (c *Client) handleVoice(pkt *protocol.Packet) { } key, nonce := crypto.GenerateKeyNonce(&echoPkt.Header, true) - // Meta for Client->Server: PID(2) + CID(2) + PT(1) meta := make([]byte, 5) binary.BigEndian.PutUint16(meta[0:2], echoPkt.Header.PacketID) binary.BigEndian.PutUint16(meta[2:4], echoPkt.Header.ClientID) @@ -55,7 +174,6 @@ func (c *Client) handleVoice(pkt *protocol.Packet) { echoPkt.Data = encData copy(echoPkt.Header.MAC[:], mac) } else { - // If no encryption keys, use SharedMac if available, otherwise HandshakeMac if c.Handshake != nil && len(c.Handshake.SharedMac) > 0 { copy(echoPkt.Header.MAC[:], c.Handshake.SharedMac) } else { diff --git a/libgcc_s_seh-1.dll b/libgcc_s_seh-1.dll new file mode 100644 index 0000000..cf56331 Binary files /dev/null and b/libgcc_s_seh-1.dll differ diff --git a/libiconv-2.dll b/libiconv-2.dll new file mode 100644 index 0000000..3cace95 Binary files /dev/null and b/libiconv-2.dll differ diff --git a/libintl-8.dll b/libintl-8.dll new file mode 100644 index 0000000..aae1c80 Binary files /dev/null and b/libintl-8.dll differ diff --git a/libogg-0.dll b/libogg-0.dll new file mode 100644 index 0000000..96dd955 Binary files /dev/null and b/libogg-0.dll differ diff --git a/libopus-0.dll b/libopus-0.dll new file mode 100644 index 0000000..6e2dad9 Binary files /dev/null and b/libopus-0.dll differ diff --git a/libopusfile-0.dll b/libopusfile-0.dll new file mode 100644 index 0000000..dae391f Binary files /dev/null and b/libopusfile-0.dll differ diff --git a/libstdc++-6.dll b/libstdc++-6.dll new file mode 100644 index 0000000..82f9ae1 Binary files /dev/null and b/libstdc++-6.dll differ diff --git a/libwinpthread-1.dll b/libwinpthread-1.dll new file mode 100644 index 0000000..f654ffe Binary files /dev/null and b/libwinpthread-1.dll differ diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..22bb02c --- /dev/null +++ b/run.ps1 @@ -0,0 +1,5 @@ +$env:PATH = "D:\esto_al_path\msys64\mingw64\bin;$env:PATH" +$env:PKG_CONFIG_PATH = "D:\esto_al_path\msys64\mingw64\lib\pkgconfig" + +Write-Host "Starting TeamSpeak Client (Windows Native)..." -ForegroundColor Cyan +go run ./cmd/client/main.go --server localhost:9987 diff --git a/ts-client.exe b/ts-client.exe new file mode 100644 index 0000000..e861501 Binary files /dev/null and b/ts-client.exe differ