├── dist └── bin │ ├── win32-x64 │ ├── tier0.dll │ └── vaudio_celt.dll │ ├── darwin-x64 │ ├── libtier0.dylib │ ├── libvstdlib.dylib │ └── vaudio_celt.dylib │ └── linux-x64 │ ├── libtier0_client.so │ └── vaudio_celt_client.so ├── .vscode ├── settings.json └── launch.json ├── .gitignore ├── common ├── fs.go ├── mode.go ├── parsing.go └── errors.go ├── csgo ├── dlfcn_win.h ├── decoder.h ├── dlfcn_win.c ├── decoder.c └── extractor.go ├── package.json ├── opus.pc.example ├── LICENSE ├── go.mod ├── Makefile ├── cs2 ├── chunk.go ├── decoder.go ├── bitread.go └── extractor.go ├── csgo_voice_extractor.go ├── go.sum ├── .github └── workflows │ └── publish.yml └── README.md /dist/bin/win32-x64/tier0.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akiver/csgo-voice-extractor/HEAD/dist/bin/win32-x64/tier0.dll -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "*.wav": true, 4 | "*.bin": true, 5 | "dist": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /dist/bin/darwin-x64/libtier0.dylib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akiver/csgo-voice-extractor/HEAD/dist/bin/darwin-x64/libtier0.dylib -------------------------------------------------------------------------------- /dist/bin/win32-x64/vaudio_celt.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akiver/csgo-voice-extractor/HEAD/dist/bin/win32-x64/vaudio_celt.dll -------------------------------------------------------------------------------- /dist/bin/darwin-x64/libvstdlib.dylib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akiver/csgo-voice-extractor/HEAD/dist/bin/darwin-x64/libvstdlib.dylib -------------------------------------------------------------------------------- /dist/bin/darwin-x64/vaudio_celt.dylib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akiver/csgo-voice-extractor/HEAD/dist/bin/darwin-x64/vaudio_celt.dylib -------------------------------------------------------------------------------- /dist/bin/linux-x64/libtier0_client.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akiver/csgo-voice-extractor/HEAD/dist/bin/linux-x64/libtier0_client.so -------------------------------------------------------------------------------- /dist/bin/linux-x64/vaudio_celt_client.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akiver/csgo-voice-extractor/HEAD/dist/bin/linux-x64/vaudio_celt_client.so -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | csgove 3 | *.bin 4 | *.wav 5 | *.dem 6 | *.pc 7 | opus 8 | dist/**/opus.dll 9 | dist/**/libopus.0.dylib 10 | dist/**/libopus.so.0 11 | -------------------------------------------------------------------------------- /common/fs.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "os" 4 | 5 | func CreateWavFile(wavFilePath string) (*os.File, error) { 6 | file, err := os.Create(wavFilePath) 7 | if err != nil { 8 | HandleError(Error{ 9 | Message: "Couldn't create WAV file", 10 | Err: err, 11 | ExitCode: WavFileCreationError, 12 | }) 13 | } 14 | 15 | return file, err 16 | } 17 | -------------------------------------------------------------------------------- /csgo/dlfcn_win.h: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | #ifndef DLFCN_H 4 | #define DLFCN_H 5 | 6 | #define RTLD_LAZY 0x1 7 | 8 | // Small wrapper around the Windows API to mimic POSIX dynamic library loading functions. 9 | void *dlopen(const char *filename, int flag); 10 | int dlclose(void *handle); 11 | void *dlsym(void *handle, const char *name); 12 | const char *dlerror(); 13 | 14 | #endif -------------------------------------------------------------------------------- /common/mode.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type Mode string 4 | 5 | const ( 6 | ModeSplitCompact Mode = "split-compact" // 1 wav file per player that contains all of the player's voice lines in one continuous sequence without silence 7 | ModeSplitFull Mode = "split-full" // 1 wav file per player that contains all of the player's voice lines in one continuous sequence with silence (demo length) 8 | ModeSingleFull Mode = "single-full" // Single wav file that contains all of the voice lines from all players in one continuous sequence with silence (demo length) 9 | ) 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@akiver/csgo-voice-extractor", 3 | "version": "3.1.3", 4 | "description": "CLI to export players' voices from CSGO/CS2 demos into WAV files.", 5 | "author": "AkiVer", 6 | "license": "MIT", 7 | "bugs": { 8 | "url": "https://github.com/akiver/csgo-voice-extractor/issues" 9 | }, 10 | "homepage": "https://github.com/akiver/csgo-voice-extractor#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/akiver/csgo-voice-extractor.git" 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "os": [ 19 | "darwin", 20 | "linux", 21 | "win32" 22 | ], 23 | "cpu": [ 24 | "x64", 25 | "arm64" 26 | ], 27 | "keywords": [ 28 | "Counter-Strike", 29 | "CS", 30 | "CS2", 31 | "CSGO", 32 | "audio" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /csgo/decoder.h: -------------------------------------------------------------------------------- 1 | #ifndef _AUDIO_H 2 | #define _AUDIO_H 3 | 4 | #if _WIN32 5 | #include 6 | #include "dlfcn_win.h" 7 | #define LIB_NAME "vaudio_celt.dll" 8 | #elif __APPLE__ 9 | #include 10 | #define LIB_NAME "vaudio_celt.dylib" 11 | #else 12 | #include 13 | #define LIB_NAME "vaudio_celt_client.so" 14 | #endif 15 | 16 | #define FRAME_SIZE 512 17 | #define SAMPLE_RATE 22050 18 | #define PACKET_SIZE 64 19 | 20 | typedef struct CELTMode CELTMode; 21 | typedef struct CELTDecoder CELTDecoder; 22 | typedef struct CELTEncoder CELTEncoder; 23 | typedef CELTMode* CeltModeCreateFunc(int32_t, int, int *error); 24 | typedef CELTDecoder* CeltDecoderCreateCustomFunc(CELTMode*, int, int *error); 25 | typedef int CeltDecodeFunc(CELTDecoder *st, const unsigned char *data, int len, int16_t *pcm, int frame_size); 26 | 27 | int Init(const char *binariesPath); 28 | int Release(); 29 | int Decode(int dataSize, unsigned char *data, char *pcmOut, int maxPcmBytes); 30 | 31 | #endif 32 | -------------------------------------------------------------------------------- /opus.pc.example: -------------------------------------------------------------------------------- 1 | # https://github.com/hraban/opus uses pkg-config to link Opus. 2 | # This pkg-config script is used only on Windows because there is no easy way like on Unix system to setup it. 3 | # It's based on the official script https://github.com/xiph/opus/blob/master/opus.pc.in 4 | # This script assumes that the opus.dll and C header files are located in the default TDM-GCC installation location! 5 | # If your GCC installation location is different, you have to adjust the "prefix" variable below. 6 | # 7 | # C header files have to be in a "include/opus" folder. It should be "C:/TDM-GCC-64/bin/include/opus" when using TDM. 8 | # 9 | # Opus codec reference implementation pkg-config file 10 | 11 | prefix=C:/TDM-GCC-64/bin 12 | exec_prefix=${prefix} 13 | libdir=${exec_prefix} 14 | includedir=${prefix}/include 15 | 16 | Name: Opus 17 | Description: Opus IETF audio codec (floating-point build) 18 | URL: https://opus-codec.org/ 19 | Version: 1.4 20 | Requires: 21 | Conflicts: 22 | Libs: -L${libdir} -lopus 23 | Libs.private: 24 | Cflags: -I${includedir}/opus 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 AkiVer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/akiver/csgo-voice-extractor 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/go-audio/audio v1.0.0 9 | github.com/go-audio/wav v1.1.0 10 | github.com/markus-wa/demoinfocs-golang/v4 v4.4.0 11 | github.com/markus-wa/gobitread v0.2.4 12 | github.com/pkg/errors v0.9.1 13 | github.com/youpy/go-wav v0.3.2 14 | google.golang.org/protobuf v1.36.11 15 | gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 16 | ) 17 | 18 | require ( 19 | github.com/go-audio/riff v1.0.0 // indirect 20 | github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289 // indirect 21 | github.com/golang/snappy v1.0.0 // indirect 22 | github.com/markus-wa/go-unassert v0.1.3 // indirect 23 | github.com/markus-wa/godispatch v1.4.1 // indirect 24 | github.com/markus-wa/ice-cipher-go v0.0.0-20230901094113-348096939ba7 // indirect 25 | github.com/markus-wa/quickhull-go/v2 v2.2.0 // indirect 26 | github.com/oklog/ulid/v2 v2.1.1 // indirect 27 | github.com/youpy/go-riff v0.1.0 // indirect 28 | github.com/zaf/g711 v1.4.0 // indirect 29 | ) 30 | 31 | replace github.com/markus-wa/demoinfocs-golang/v4 v4.4.0 => github.com/markus-wa/demoinfocs-golang/v4 v4.4.1-0.20251218225334-6ea41415523b 32 | -------------------------------------------------------------------------------- /common/parsing.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | 8 | dem "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs" 9 | ) 10 | 11 | type ExtractOptions struct { 12 | DemoPath string 13 | DemoName string 14 | File *os.File 15 | OutputPath string 16 | Mode Mode 17 | SteamIDs []string 18 | } 19 | 20 | type VoiceSegment struct { 21 | Data []byte 22 | Timestamp float64 // in seconds 23 | } 24 | 25 | var playerNameCache = make(map[uint64]string) 26 | 27 | func GetPlayerID(parser dem.Parser, steamID uint64) string { 28 | if name, ok := playerNameCache[steamID]; ok { 29 | return fmt.Sprintf("%s_%d", name, steamID) 30 | } 31 | 32 | playerName := "" 33 | for _, player := range parser.GameState().Participants().All() { 34 | if player.SteamID64 == steamID { 35 | invalidCharsRegex := regexp.MustCompile(`[\\/:*?"<>|]`) 36 | playerName = invalidCharsRegex.ReplaceAllString(player.Name, "") 37 | playerNameCache[steamID] = playerName 38 | break 39 | } 40 | } 41 | 42 | if playerName == "" { 43 | fmt.Println("Unable to find player's name with SteamID", steamID) 44 | return "" 45 | } 46 | 47 | return fmt.Sprintf("%s_%d", playerName, steamID) 48 | } 49 | -------------------------------------------------------------------------------- /csgo/dlfcn_win.c: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | static struct LastError { 10 | long code; 11 | const char *functionName; 12 | } lastError = { 13 | 0, 14 | NULL 15 | }; 16 | 17 | void *dlopen(const char *filename, int flags) { 18 | HINSTANCE handle = LoadLibraryEx(filename, NULL, LOAD_WITH_ALTERED_SEARCH_PATH); 19 | if (handle == NULL) { 20 | lastError.code = GetLastError(); 21 | lastError.functionName = "dlopen"; 22 | } 23 | 24 | return handle; 25 | } 26 | 27 | int dlclose(void *handle) { 28 | BOOL ok = FreeLibrary(handle); 29 | if (!ok) { 30 | lastError.code = GetLastError(); 31 | lastError.functionName = "dlclose"; 32 | return -1; 33 | } 34 | 35 | return 0; 36 | } 37 | 38 | void *dlsym(void *handle, const char *name) { 39 | FARPROC fp = GetProcAddress(handle, name); 40 | 41 | if (fp == NULL) { 42 | lastError.code = GetLastError(); 43 | lastError.functionName = "dlsym"; 44 | } 45 | 46 | return (void *)(intptr_t)fp; 47 | } 48 | 49 | const char *dlerror() { 50 | static char error[256]; 51 | 52 | if (lastError.code) { 53 | sprintf(error, "%s error #%ld", lastError.functionName, lastError.code); 54 | return error; 55 | } 56 | 57 | return NULL; 58 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO_FLAGS += "-ldflags=-s -w" 2 | GO_FLAGS += -trimpath 3 | GO_FLAGS += -tags nolibopusfile 4 | BINARY_NAME=csgove 5 | 6 | .DEFAULT_GOAL := help 7 | 8 | build-unixlike: 9 | @test -n "$(GOOS)" || (echo "The environment variable GOOS must be provided" && false) 10 | @test -n "$(GOARCH)" || (echo "The environment variable GOARCH must be provided" && false) 11 | @test -n "$(BIN_DIR)" || (echo "The environment variable BIN_DIR must be provided" && false) 12 | CGO_ENABLED=1 GOOS="$(GOOS)" GOARCH="$(GOARCH)" go build $(GO_FLAGS) -o "$(BIN_DIR)/$(BINARY_NAME)" 13 | 14 | build-darwin: ## Build for Darwin 15 | @test -f dist/bin/darwin-x64/libopus.0.dylib || (echo "dist/bin/darwin-x64/libopus.0.dylib is missing" && false) 16 | @$(MAKE) GOOS=darwin GOARCH=amd64 CGO_LDFLAGS="-L/usr/local/Cellar" BIN_DIR=dist/bin/darwin-x64 build-unixlike 17 | 18 | build-linux: ## Build for Linux 19 | @test -f dist/bin/linux-x64/libopus.so.0 || (echo "dist/bin/linux-x64/libopus.so.0 is missing" && false) 20 | @$(MAKE) GOOS=linux GOARCH=amd64 BIN_DIR=dist/bin/linux-x64 build-unixlike 21 | 22 | build-windows: ## Build for Windows 23 | @test -f dist/bin/win32-x64/opus.dll || (echo "dist/bin/win32-x64/opus.dll is missing" && false) 24 | PKG_CONFIG_PATH=$(shell realpath .) CGO_ENABLED=1 GOOS=windows GOARCH=386 go build $(GO_FLAGS) -o dist/bin/win32-x64/$(BINARY_NAME).exe 25 | 26 | clean: ## Clean up project files 27 | rm -f *.wav *.bin 28 | 29 | help: 30 | @echo 'Targets:' 31 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 32 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch CS2", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "auto", 9 | "program": ".", 10 | "windows": { 11 | "env": { 12 | "LD_LIBRARY_PATH": "./dist/bin/win32-x64", 13 | "CGO_ENABLED": "1", 14 | "GOARCH": "386", 15 | "PKG_CONFIG_PATH": "${workspaceRoot}" 16 | }, 17 | "args": ["cs2.dem"], 18 | "buildFlags": "-tags nolibopusfile" 19 | }, 20 | "osx": { 21 | "env": { 22 | "DYLD_LIBRARY_PATH": "./dist/bin/darwin-x64", 23 | "CGO_ENABLED": "1", 24 | "GOARCH": "amd64" 25 | }, 26 | "args": ["cs2.dem"], 27 | "buildFlags": "-tags nolibopusfile" 28 | }, 29 | "linux": { 30 | "env": { 31 | "LD_LIBRARY_PATH": "./dist/bin/linux-x64", 32 | "CGO_ENABLED": "1", 33 | "GOARCH": "amd64" 34 | }, 35 | "args": ["cs2.dem"], 36 | "buildFlags": "-tags nolibopusfile" 37 | } 38 | }, 39 | { 40 | "name": "Launch CSGO", 41 | "type": "go", 42 | "request": "launch", 43 | "mode": "auto", 44 | "program": ".", 45 | "windows": { 46 | "env": { 47 | "LD_LIBRARY_PATH": "./dist/bin/win32-x64", 48 | "CGO_ENABLED": "1", 49 | "GOARCH": "386", 50 | "PKG_CONFIG_PATH": "${workspaceRoot}" 51 | }, 52 | "args": ["csgo.dem"], 53 | "buildFlags": "-tags nolibopusfile" 54 | }, 55 | "osx": { 56 | "env": { 57 | "DYLD_LIBRARY_PATH": "./dist/bin/darwin-x64", 58 | "CGO_ENABLED": "1", 59 | "GOARCH": "amd64" 60 | }, 61 | "args": ["csgo.dem"], 62 | "buildFlags": "-tags nolibopusfile" 63 | }, 64 | "linux": { 65 | "env": { 66 | "LD_LIBRARY_PATH": "./dist/bin/linux-x64", 67 | "CGO_ENABLED": "1", 68 | "GOARCH": "amd64" 69 | }, 70 | "buildFlags": "-tags nolibopusfile", 71 | "args": ["csgo.dem"] 72 | } 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /cs2/chunk.go: -------------------------------------------------------------------------------- 1 | package cs2 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "errors" 7 | "fmt" 8 | "hash/crc32" 9 | ) 10 | 11 | const ( 12 | minimumLength = 18 13 | ) 14 | 15 | var ( 16 | ErrInsufficientData = errors.New("insufficient amount of data to chunk") 17 | ErrInvalidVoicePacket = errors.New("invalid voice packet") 18 | ErrMismatchChecksum = errors.New("mismatching voice data checksum") 19 | ) 20 | 21 | type Chunk struct { 22 | SteamID uint64 23 | SampleRate uint16 24 | Length uint16 25 | Data []byte 26 | Checksum uint32 27 | } 28 | 29 | func DecodeChunk(b []byte) (*Chunk, error) { 30 | bLen := len(b) 31 | 32 | if bLen < minimumLength { 33 | return nil, fmt.Errorf("%w (received: %d bytes, expected at least %d bytes)", ErrInsufficientData, bLen, minimumLength) 34 | } 35 | 36 | chunk := &Chunk{} 37 | 38 | buf := bytes.NewBuffer(b) 39 | 40 | if err := binary.Read(buf, binary.LittleEndian, &chunk.SteamID); err != nil { 41 | return nil, err 42 | } 43 | 44 | var payloadType byte 45 | if err := binary.Read(buf, binary.LittleEndian, &payloadType); err != nil { 46 | return nil, err 47 | } 48 | 49 | if payloadType != 0x0B { 50 | return nil, fmt.Errorf("%w (received %x, expected %x)", ErrInvalidVoicePacket, payloadType, 0x0B) 51 | } 52 | 53 | if err := binary.Read(buf, binary.LittleEndian, &chunk.SampleRate); err != nil { 54 | return nil, err 55 | } 56 | 57 | var voiceType byte 58 | if err := binary.Read(buf, binary.LittleEndian, &voiceType); err != nil { 59 | return nil, err 60 | } 61 | 62 | if err := binary.Read(buf, binary.LittleEndian, &chunk.Length); err != nil { 63 | return nil, err 64 | } 65 | 66 | switch voiceType { 67 | case 0x6: 68 | remaining := buf.Len() 69 | chunkLen := int(chunk.Length) 70 | 71 | if remaining < chunkLen { 72 | return nil, fmt.Errorf("%w (received: %d bytes, expected at least %d bytes)", ErrInsufficientData, bLen, (bLen + (chunkLen - remaining))) 73 | } 74 | 75 | data := make([]byte, chunkLen) 76 | n, err := buf.Read(data) 77 | 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | // Is this even possible 83 | if n != chunkLen { 84 | return nil, fmt.Errorf("%w (expected to read %d bytes, but read %d bytes)", ErrInsufficientData, chunkLen, n) 85 | } 86 | 87 | chunk.Data = data 88 | case 0x0: 89 | // no-op, detect silence if chunk.Data is empty 90 | // the length would the number of silence frames 91 | default: 92 | return nil, fmt.Errorf("%w (expected 0x6 or 0x0 voice data, received %x)", ErrInvalidVoicePacket, voiceType) 93 | } 94 | 95 | remaining := buf.Len() 96 | 97 | if remaining != 4 { 98 | return nil, fmt.Errorf("%w (has %d bytes remaining, expected 4 bytes remaining)", ErrInvalidVoicePacket, remaining) 99 | } 100 | 101 | if err := binary.Read(buf, binary.LittleEndian, &chunk.Checksum); err != nil { 102 | return nil, err 103 | } 104 | 105 | actualChecksum := crc32.ChecksumIEEE(b[0 : bLen-4]) 106 | 107 | if chunk.Checksum != actualChecksum { 108 | return nil, fmt.Errorf("%w (received %x, expected %x)", ErrMismatchChecksum, chunk.Checksum, actualChecksum) 109 | } 110 | 111 | return chunk, nil 112 | } 113 | -------------------------------------------------------------------------------- /cs2/decoder.go: -------------------------------------------------------------------------------- 1 | package cs2 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | 7 | "github.com/akiver/csgo-voice-extractor/common" 8 | "gopkg.in/hraban/opus.v2" 9 | ) 10 | 11 | const ( 12 | FrameSize = 480 13 | ) 14 | 15 | type SteamDecoder struct { 16 | decoder *opus.Decoder 17 | currentFrame uint16 18 | } 19 | 20 | func NewSteamDecoder(sampleRate int, channels int) (*SteamDecoder, error) { 21 | decoder, err := opus.NewDecoder(sampleRate, channels) 22 | 23 | if err != nil { 24 | common.HandleError(common.Error{ 25 | Message: "Failed to create Steam decoder", 26 | Err: err, 27 | ExitCode: common.DecodingError, 28 | }) 29 | return nil, err 30 | } 31 | 32 | return &SteamDecoder{ 33 | decoder: decoder, 34 | currentFrame: 0, 35 | }, nil 36 | } 37 | 38 | func (d *SteamDecoder) Decode(b []byte) ([]float32, error) { 39 | buf := bytes.NewBuffer(b) 40 | 41 | output := make([]float32, 0, 1024) 42 | 43 | for buf.Len() != 0 { 44 | var chunkLen int16 45 | if err := binary.Read(buf, binary.LittleEndian, &chunkLen); err != nil { 46 | return nil, err 47 | } 48 | 49 | if chunkLen == -1 { 50 | d.currentFrame = 0 51 | break 52 | } 53 | 54 | var currentFrame uint16 55 | if err := binary.Read(buf, binary.LittleEndian, ¤tFrame); err != nil { 56 | return nil, err 57 | } 58 | 59 | previousFrame := d.currentFrame 60 | 61 | chunk := make([]byte, chunkLen) 62 | n, err := buf.Read(chunk) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | if n != int(chunkLen) { 68 | return nil, ErrInvalidVoicePacket 69 | } 70 | 71 | if currentFrame >= previousFrame { 72 | if currentFrame == previousFrame { 73 | d.currentFrame = currentFrame + 1 74 | 75 | decoded, err := d.decodeSteamChunk(chunk) 76 | 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | output = append(output, decoded...) 82 | } else { 83 | decoded, err := d.decodeLoss(currentFrame - previousFrame) 84 | 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | output = append(output, decoded...) 90 | } 91 | } 92 | } 93 | 94 | return output, nil 95 | } 96 | 97 | func (d *SteamDecoder) decodeSteamChunk(b []byte) ([]float32, error) { 98 | o := make([]float32, FrameSize) 99 | 100 | n, err := d.decoder.DecodeFloat32(b, o) 101 | 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | return o[:n], nil 107 | } 108 | 109 | func (d *SteamDecoder) decodeLoss(samples uint16) ([]float32, error) { 110 | loss := min(samples, 10) 111 | 112 | o := make([]float32, 0, FrameSize*loss) 113 | 114 | for i := 0; i < int(loss); i += 1 { 115 | t := make([]float32, FrameSize) 116 | 117 | if err := d.decoder.DecodePLCFloat32(t); err != nil { 118 | return nil, err 119 | } 120 | 121 | o = append(o, t...) 122 | } 123 | 124 | return o, nil 125 | } 126 | 127 | func NewOpusDecoder(sampleRate int, channels int) (decoder *opus.Decoder, err error) { 128 | decoder, err = opus.NewDecoder(sampleRate, channels) 129 | if err != nil { 130 | common.HandleError(common.Error{ 131 | Message: "Failed to create Opus decoder", 132 | Err: err, 133 | ExitCode: common.DecodingError, 134 | }) 135 | } 136 | 137 | return decoder, err 138 | } 139 | 140 | func Decode(decoder *opus.Decoder, data []byte) (pcm []float32, err error) { 141 | pcm = make([]float32, 1024) 142 | 143 | writtenLength, err := decoder.DecodeFloat32(data, pcm) 144 | if err != nil { 145 | return 146 | } 147 | 148 | return pcm[:writtenLength], nil 149 | } 150 | -------------------------------------------------------------------------------- /csgo/decoder.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include "decoder.h" 5 | 6 | void *handle; 7 | CeltDecodeFunc* celtDecode; 8 | CELTDecoder *decoder; 9 | 10 | int Init(const char *csgoLibPath) { 11 | // The CSGO audio lib depends on an additional lib "tier0" which is not located on standard paths but in the CSGO folder. 12 | // It means that loading the audio lib without LD_LIBRARY_PATH would fail because it won't be able to find the tier0 lib. 13 | // That's why LD_LIBRARY_PATH must be set on unix, even the script used to start CSGO on unix does it (see csgo.sh in the game folder). 14 | // On Windows, LoadLibraryEx is able to load a DLL and its additional dependencies if they are in the same folder. 15 | #if _WIN32 16 | char csgoLibraryFullPath[1024]; 17 | snprintf(csgoLibraryFullPath, sizeof(csgoLibraryFullPath), "%s\\%s", csgoLibPath, LIB_NAME); 18 | handle = dlopen(csgoLibraryFullPath, RTLD_LAZY); 19 | #else 20 | handle = dlopen(LIB_NAME, RTLD_LAZY); 21 | #endif 22 | 23 | if (!handle) { 24 | fprintf(stderr, "dlopen failed: %s\n", dlerror()); 25 | return EXIT_FAILURE; 26 | } 27 | 28 | CeltModeCreateFunc* celtModeCreate = dlsym(handle, "celt_mode_create"); 29 | if (celtModeCreate == NULL) { 30 | fprintf(stderr, "dlsym celt_mode_create failed: %s\n", dlerror()); 31 | Release(); 32 | return EXIT_FAILURE; 33 | } 34 | 35 | CeltDecoderCreateCustomFunc* celtDecoderCreateCustom = dlsym(handle, "celt_decoder_create_custom"); 36 | if (celtDecoderCreateCustom == NULL) { 37 | fprintf(stderr, "dlsym celt_decoder_create_custom failed: %s\n", dlerror()); 38 | Release(); 39 | return EXIT_FAILURE; 40 | } 41 | 42 | celtDecode = dlsym(handle, "celt_decode"); 43 | if (celtDecode == NULL) { 44 | fprintf(stderr, "dlsym celt_decode failed: %s\n", dlerror()); 45 | Release(); 46 | return EXIT_FAILURE; 47 | } 48 | 49 | CELTMode *mode = celtModeCreate(SAMPLE_RATE, FRAME_SIZE, NULL); 50 | if (mode == NULL) { 51 | fprintf(stderr, "Mode creation failed\n"); 52 | Release(); 53 | return EXIT_FAILURE; 54 | } 55 | 56 | decoder = celtDecoderCreateCustom(mode, 1, NULL); 57 | if (decoder == NULL) { 58 | fprintf(stderr, "Decoder creation failed\n"); 59 | Release(); 60 | return EXIT_FAILURE; 61 | } 62 | 63 | return EXIT_SUCCESS; 64 | } 65 | 66 | int Release() { 67 | int closed = dlclose(handle); 68 | if (closed != 0) { 69 | fprintf(stderr, "Release failed: %s\n", dlerror()); 70 | return EXIT_FAILURE; 71 | } 72 | 73 | return EXIT_SUCCESS; 74 | } 75 | 76 | 77 | int Decode(int dataSize, unsigned char *data, char *pcmOut, int maxPcmBytes) { 78 | size_t outputSize = (dataSize / PACKET_SIZE) * FRAME_SIZE * 2; 79 | if (outputSize > (size_t)maxPcmBytes) { 80 | outputSize = maxPcmBytes; 81 | } 82 | int16_t* output = (int16_t*)pcmOut; 83 | 84 | int read = 0; 85 | int written = 0; 86 | 87 | while (read < dataSize && (written + FRAME_SIZE * 2) <= maxPcmBytes) { 88 | int result = celtDecode(decoder, data + read, PACKET_SIZE, output + (written / 2), FRAME_SIZE); 89 | if (result < 0) { 90 | continue; 91 | } 92 | 93 | read += PACKET_SIZE; 94 | written += FRAME_SIZE * 2; 95 | } 96 | 97 | if (read < dataSize) { 98 | fprintf(stderr, "Output buffer too small, some audio data was skipped! Processed %d of %d bytes.\n", read, dataSize); 99 | } 100 | 101 | return written; 102 | } 103 | -------------------------------------------------------------------------------- /common/errors.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | var ShouldExitOnFirstError = false 12 | var LibrariesPath string 13 | 14 | type ExitCode int 15 | 16 | type Error struct { 17 | Message string 18 | Err error 19 | ExitCode ExitCode 20 | } 21 | 22 | const ( 23 | InvalidArguments ExitCode = 10 24 | LoadCsgoLibError ExitCode = 11 25 | DemoNotFound ExitCode = 12 26 | ParsingError ExitCode = 13 27 | UnsupportedAudioCodec ExitCode = 14 28 | NoVoiceDataFound ExitCode = 15 29 | DecodingError ExitCode = 16 30 | WavFileCreationError ExitCode = 17 31 | OpenDemoError ExitCode = 18 32 | UnsupportedDemoFormat ExitCode = 19 33 | MissingLibraryFiles ExitCode = 20 34 | ) 35 | 36 | type UnsupportedCodec struct { 37 | Name string 38 | Quality int32 39 | Version int32 40 | } 41 | 42 | var UnsupportedCodecError *UnsupportedCodec 43 | 44 | func (err *Error) Error() string { 45 | if err.Err != nil { 46 | return fmt.Sprintf("%s\n%s", err.Message, err.Err.Error()) 47 | } 48 | 49 | return fmt.Sprintf("%s\n", err.Message) 50 | } 51 | 52 | func NewError(message string, err error, exitCode ExitCode) Error { 53 | return Error{ 54 | Message: message, 55 | Err: err, 56 | ExitCode: exitCode, 57 | } 58 | } 59 | 60 | func NewDecodingError(message string, err error) Error { 61 | return NewError(message, err, DecodingError) 62 | } 63 | 64 | func NewWavFileCreationError(message string, err error) Error { 65 | return NewError(message, err, WavFileCreationError) 66 | } 67 | 68 | func HandleError(err Error) Error { 69 | fmt.Fprint(os.Stderr, err.Error()) 70 | if ShouldExitOnFirstError { 71 | os.Exit(int(err.ExitCode)) 72 | } 73 | 74 | return err 75 | } 76 | 77 | func HandleInvalidArgument(message string, err error) Error { 78 | ShouldExitOnFirstError = true 79 | 80 | return HandleError(Error{ 81 | Message: message, 82 | Err: err, 83 | ExitCode: InvalidArguments, 84 | }) 85 | } 86 | 87 | func AssertLibraryFilesExist() { 88 | var ldLibraryPath string 89 | if runtime.GOOS == "darwin" { 90 | ldLibraryPath = os.Getenv("DYLD_LIBRARY_PATH") 91 | } else { 92 | ldLibraryPath = os.Getenv("LD_LIBRARY_PATH") 93 | } 94 | 95 | // The env variable LD_LIBRARY_PATH is mandatory only on unix platforms, see decoder.c for details. 96 | if ldLibraryPath == "" && runtime.GOOS != "windows" { 97 | if runtime.GOOS == "darwin" { 98 | HandleInvalidArgument("DYLD_LIBRARY_PATH is missing, usage example: DYLD_LIBRARY_PATH=. csgove myDemo.dem", nil) 99 | } else { 100 | HandleInvalidArgument("LD_LIBRARY_PATH is missing, usage example: LD_LIBRARY_PATH=. csgove myDemo.dem", nil) 101 | } 102 | } 103 | 104 | var err error 105 | LibrariesPath, err = filepath.Abs(ldLibraryPath) 106 | if err != nil { 107 | HandleInvalidArgument("Invalid library path provided", err) 108 | } 109 | 110 | LibrariesPath = strings.TrimSuffix(LibrariesPath, string(os.PathSeparator)) 111 | 112 | _, err = os.Stat(LibrariesPath) 113 | if os.IsNotExist(err) { 114 | HandleInvalidArgument("Library folder doesn't exists", err) 115 | } 116 | 117 | var requiredFiles []string 118 | switch runtime.GOOS { 119 | case "windows": 120 | requiredFiles = []string{"vaudio_celt.dll", "tier0.dll", "opus.dll"} 121 | case "darwin": 122 | requiredFiles = []string{"vaudio_celt.dylib", "libtier0.dylib", "libvstdlib.dylib", "libopus.0.dylib"} 123 | default: 124 | requiredFiles = []string{"vaudio_celt_client.so", "libtier0_client.so", "libopus.so.0"} 125 | } 126 | 127 | for _, requiredFile := range requiredFiles { 128 | _, err = os.Stat(LibrariesPath + string(os.PathSeparator) + requiredFile) 129 | if os.IsNotExist(err) { 130 | ShouldExitOnFirstError = true 131 | HandleError(Error{ 132 | Message: "The required library file " + requiredFile + " doesn't exists", 133 | Err: err, 134 | ExitCode: MissingLibraryFiles, 135 | }) 136 | } 137 | } 138 | } 139 | 140 | func AssertCodecIsSupported() { 141 | if UnsupportedCodecError != nil { 142 | HandleError(Error{ 143 | Message: fmt.Sprintf( 144 | "unsupported audio codec: %s %d %d", 145 | UnsupportedCodecError.Name, 146 | UnsupportedCodecError.Quality, 147 | UnsupportedCodecError.Version, 148 | ), 149 | ExitCode: UnsupportedAudioCodec, 150 | }) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /cs2/bitread.go: -------------------------------------------------------------------------------- 1 | // Credits https://github.com/markus-wa/demoinfocs-golang/blob/master/internal/bitread/bitread.go 2 | package cs2 3 | 4 | import ( 5 | "io" 6 | "math" 7 | "sync" 8 | 9 | bitread "github.com/markus-wa/gobitread" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | const ( 14 | smallBuffer = 512 15 | largeBuffer = 1024 * 128 16 | maxVarInt32Bytes = 5 17 | maxVarintBytes = 10 18 | ) 19 | 20 | // BitReader wraps github.com/markus-wa/gobitread.BitReader and provides additional functionality specific to CS:GO demos. 21 | type BitReader struct { 22 | bitread.BitReader 23 | buffer *[]byte 24 | } 25 | 26 | // ReadString reads a variable length string. 27 | func (r *BitReader) ReadString() string { 28 | // Valve also uses this sooo 29 | const valveMaxStringLength = 4096 30 | return r.readStringLimited(valveMaxStringLength, false) 31 | } 32 | 33 | func (r *BitReader) readStringLimited(limit int, endOnNewLine bool) string { 34 | const minStringBufferLength = 256 35 | result := make([]byte, 0, minStringBufferLength) 36 | 37 | for i := 0; i < limit; i++ { 38 | b := r.ReadSingleByte() 39 | if b == 0 || (endOnNewLine && b == '\n') { 40 | break 41 | } 42 | 43 | result = append(result, b) 44 | } 45 | 46 | return string(result) 47 | } 48 | 49 | // ReadFloat reads a 32-bit float. Wraps ReadInt(). 50 | func (r *BitReader) ReadFloat() float32 { 51 | return math.Float32frombits(uint32(r.ReadInt(32))) 52 | } 53 | 54 | // ReadVarInt32 reads a variable size unsigned int (max 32-bit). 55 | func (r *BitReader) ReadVarInt32() uint32 { 56 | var ( 57 | res uint32 58 | b uint32 = 0x80 59 | ) 60 | 61 | for count := uint(0); b&0x80 != 0 && count != maxVarInt32Bytes; count++ { 62 | b = uint32(r.ReadSingleByte()) 63 | res |= (b & 0x7f) << (7 * count) 64 | } 65 | 66 | return res 67 | } 68 | 69 | // ReadVarInt64 reads a variable size unsigned int (max 64-bit). 70 | func (r *BitReader) ReadVarInt64() uint64 { 71 | var ( 72 | res uint64 73 | b uint64 = 0x80 74 | ) 75 | 76 | for count := uint(0); b&0x80 != 0 && count != maxVarintBytes; count++ { 77 | b = uint64(r.ReadSingleByte()) 78 | res |= (b & 0x7f) << (7 * count) 79 | } 80 | 81 | return res 82 | } 83 | 84 | // ReadSignedVarInt32 reads a variable size signed int (max 32-bit). 85 | func (r *BitReader) ReadSignedVarInt32() int32 { 86 | res := r.ReadVarInt32() 87 | return int32((res >> 1) ^ -(res & 1)) 88 | } 89 | 90 | // ReadSignedVarInt64 reads a variable size signed int (max 64-bit). 91 | func (r *BitReader) ReadSignedVarInt64() int64 { 92 | res := r.ReadVarInt64() 93 | return int64((res >> 1) ^ -(res & 1)) 94 | } 95 | 96 | // ReadUBitInt reads some kind of variable size uint. 97 | // Honestly, not quite sure how it works. 98 | func (r *BitReader) ReadUBitInt() uint { 99 | res := r.ReadInt(6) 100 | switch res & (16 | 32) { 101 | case 16: 102 | res = (res & 15) | (r.ReadInt(4) << 4) 103 | case 32: 104 | res = (res & 15) | (r.ReadInt(8) << 4) 105 | case 48: 106 | res = (res & 15) | (r.ReadInt(32-4) << 4) 107 | } 108 | 109 | return res 110 | } 111 | 112 | var bitReaderPool = sync.Pool{ 113 | New: func() any { 114 | return new(BitReader) 115 | }, 116 | } 117 | 118 | // Pool puts the BitReader into a pool for future use. 119 | // Pooling BitReaders improves performance by minimizing the amount newly allocated readers. 120 | func (r *BitReader) Pool() error { 121 | err := r.Close() 122 | if err != nil { 123 | return errors.Wrap(err, "failed to close BitReader before pooling") 124 | } 125 | 126 | if len(*r.buffer) == smallBuffer { 127 | smallBufferPool.Put(r.buffer) 128 | } 129 | 130 | r.buffer = nil 131 | 132 | bitReaderPool.Put(r) 133 | 134 | return nil 135 | } 136 | 137 | func newBitReader(underlying io.Reader, buffer *[]byte) *BitReader { 138 | br := bitReaderPool.Get().(*BitReader) 139 | br.buffer = buffer 140 | br.OpenWithBuffer(underlying, *buffer) 141 | 142 | return br 143 | } 144 | 145 | var smallBufferPool = sync.Pool{ 146 | New: func() any { 147 | b := make([]byte, smallBuffer) 148 | return &b 149 | }, 150 | } 151 | 152 | // NewSmallBitReader returns a BitReader with a small buffer, suitable for short streams. 153 | func NewSmallBitReader(underlying io.Reader) *BitReader { 154 | return newBitReader(underlying, smallBufferPool.Get().(*[]byte)) 155 | } 156 | 157 | // NewLargeBitReader returns a BitReader with a large buffer, suitable for long streams (main demo file). 158 | func NewLargeBitReader(underlying io.Reader) *BitReader { 159 | b := make([]byte, largeBuffer) 160 | return newBitReader(underlying, &b) 161 | } 162 | -------------------------------------------------------------------------------- /csgo_voice_extractor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/akiver/csgo-voice-extractor/common" 12 | "github.com/akiver/csgo-voice-extractor/cs2" 13 | "github.com/akiver/csgo-voice-extractor/csgo" 14 | ) 15 | 16 | var outputPath string 17 | var demoPaths []string 18 | var mode string 19 | var steamIDs []string 20 | 21 | func computeOutputPathFlag() { 22 | if outputPath == "" { 23 | currentDirectory, err := os.Getwd() 24 | if err != nil { 25 | common.HandleInvalidArgument("Failed to get current directory", err) 26 | } 27 | outputPath = currentDirectory 28 | return 29 | } 30 | 31 | var err error 32 | outputPath, err = filepath.Abs(outputPath) 33 | if err != nil { 34 | common.HandleInvalidArgument("Invalid output path provided", err) 35 | } 36 | 37 | _, err = os.Stat(outputPath) 38 | if os.IsNotExist(err) { 39 | common.HandleInvalidArgument("Output folder doesn't exists", err) 40 | } 41 | } 42 | 43 | func computeDemoPathsArgs() { 44 | demoPaths = flag.Args() 45 | if len(demoPaths) == 0 { 46 | common.HandleInvalidArgument("No demo path provided", nil) 47 | } 48 | 49 | for _, demoPath := range demoPaths { 50 | if !strings.HasSuffix(demoPath, ".dem") { 51 | common.HandleInvalidArgument(fmt.Sprintf("Invalid demo path: %s", demoPath), nil) 52 | } 53 | } 54 | } 55 | 56 | func computeSteamIDsFlag(steamIDsFlag string) { 57 | if steamIDsFlag != "" { 58 | steamIDs = strings.Split(steamIDsFlag, ",") 59 | for i, steamID := range steamIDs { 60 | steamIDs[i] = strings.TrimSpace(steamID) 61 | } 62 | } 63 | } 64 | 65 | func parseArgs() { 66 | var steamIDsFlag string 67 | flag.StringVar(&outputPath, "output", "", "Output folder where WAV files will be written. Can be relative or absolute, default to the current directory.") 68 | flag.BoolVar(&common.ShouldExitOnFirstError, "exit-on-first-error", false, "Exit the program on at the first error encountered, default to false.") 69 | flag.StringVar(&mode, "mode", string(common.ModeSplitCompact), "Output mode. Can be 'split-compact', 'split-full' or 'single-full'. Default to 'split-compact'.") 70 | flag.StringVar(&steamIDsFlag, "steam-ids", "", "Comma-separated list of Steam IDs 64 to extract voice data for.") 71 | flag.Parse() 72 | 73 | computeSteamIDsFlag(steamIDsFlag) 74 | computeDemoPathsArgs() 75 | computeOutputPathFlag() 76 | } 77 | 78 | func getDemoTimestamp(file *os.File, demoPath string) (string, error) { 79 | buffer := make([]byte, 8) 80 | n, err := io.ReadFull(file, buffer) 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | timestamp := string(buffer[:n]) 86 | timestamp = strings.TrimRight(timestamp, "\x00") 87 | 88 | return timestamp, nil 89 | } 90 | 91 | func processDemoFile(demoPath string) { 92 | fmt.Printf("Processing demo %s\n", demoPath) 93 | 94 | file, err := os.Open(demoPath) 95 | if err != nil { 96 | if _, isOpenFileError := err.(*os.PathError); isOpenFileError { 97 | common.HandleError(common.NewError( 98 | fmt.Sprintf("Demo not found: %s", demoPath), 99 | err, 100 | common.DemoNotFound)) 101 | } else { 102 | common.HandleError(common.NewError( 103 | fmt.Sprintf("Failed to open demo: %s", demoPath), 104 | err, 105 | common.OpenDemoError)) 106 | } 107 | return 108 | } 109 | defer file.Close() 110 | 111 | timestamp, err := getDemoTimestamp(file, demoPath) 112 | if err != nil { 113 | common.HandleError(common.NewError( 114 | fmt.Sprintf("Failed to read demo timestamp: %s", demoPath), 115 | err, 116 | common.OpenDemoError)) 117 | return 118 | } 119 | 120 | _, err = file.Seek(0, 0) 121 | if err != nil { 122 | common.HandleError(common.NewError( 123 | fmt.Sprintf("Failed to reset demo file pointer: %s", demoPath), 124 | err, 125 | common.OpenDemoError)) 126 | return 127 | } 128 | 129 | options := common.ExtractOptions{ 130 | DemoPath: demoPath, 131 | DemoName: strings.TrimSuffix(filepath.Base(demoPath), filepath.Ext(demoPath)), 132 | File: file, 133 | OutputPath: outputPath, 134 | Mode: common.Mode(mode), 135 | SteamIDs: steamIDs, 136 | } 137 | 138 | switch timestamp { 139 | case "HL2DEMO": 140 | csgo.Extract(options) 141 | case "PBDEMS2": 142 | cs2.Extract(options) 143 | default: 144 | common.HandleError(common.NewError( 145 | fmt.Sprintf("Unsupported demo format: %s", timestamp), 146 | nil, 147 | common.UnsupportedDemoFormat)) 148 | } 149 | 150 | fmt.Printf("End processing demo %s\n", demoPath) 151 | } 152 | 153 | func main() { 154 | parseArgs() 155 | 156 | for _, demoPath := range demoPaths { 157 | processDemoFile(demoPath) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= 5 | github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= 6 | github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA= 7 | github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= 8 | github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g= 9 | github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE= 10 | github.com/golang/geo v0.0.0-20180826223333-635502111454/go.mod h1:vgWZ7cu0fq0KY3PpEHsocXOWJpRtkcbKemU4IUw0M60= 11 | github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289 h1:HeOFbnyPys/vx/t+d4fwZM782mnjRVtbjxVkDittTUs= 12 | github.com/golang/geo v0.0.0-20250516193853-92f93c4cb289/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA= 13 | github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= 14 | github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 15 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 16 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 17 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 18 | github.com/markus-wa/demoinfocs-golang/v4 v4.4.1-0.20251218225334-6ea41415523b h1:22ymYkvfBXU66x7rBoSh4KG4Xra8d7KQQG6eIJgJR+Q= 19 | github.com/markus-wa/demoinfocs-golang/v4 v4.4.1-0.20251218225334-6ea41415523b/go.mod h1:SfgbMznZREy98M7EjzkIPxEpZPVpbX/f9tVGSTJF3WU= 20 | github.com/markus-wa/go-unassert v0.1.3 h1:4N2fPLUS3929Rmkv94jbWskjsLiyNT2yQpCulTFFWfM= 21 | github.com/markus-wa/go-unassert v0.1.3/go.mod h1:/pqt7a0LRmdsRNYQ2nU3SGrXfw3bLXrvIkakY/6jpPY= 22 | github.com/markus-wa/gobitread v0.2.4 h1:BDr3dZnsqntDD4D8E7DzhkQlASIkQdfxCXLhWcI2K5A= 23 | github.com/markus-wa/gobitread v0.2.4/go.mod h1:PcWXMH4gx7o2CKslbkFkLyJB/aHW7JVRG3MRZe3PINg= 24 | github.com/markus-wa/godispatch v1.4.1 h1:Cdff5x33ShuX3sDmUbYWejk7tOuoHErFYMhUc2h7sLc= 25 | github.com/markus-wa/godispatch v1.4.1/go.mod h1:tk8L0yzLO4oAcFwM2sABMge0HRDJMdE8E7xm4gK/+xM= 26 | github.com/markus-wa/ice-cipher-go v0.0.0-20230901094113-348096939ba7 h1:aR9pvnlnBxifXBmzidpAiq2prLSGlkhE904qnk2sCz4= 27 | github.com/markus-wa/ice-cipher-go v0.0.0-20230901094113-348096939ba7/go.mod h1:JIsht5Oa9P50VnGJTvH2a6nkOqDFJbUeU1YRZYvdplw= 28 | github.com/markus-wa/quickhull-go/v2 v2.2.0 h1:rB99NLYeUHoZQ/aNRcGOGqjNBGmrOaRxdtqTnsTUPTA= 29 | github.com/markus-wa/quickhull-go/v2 v2.2.0/go.mod h1:EuLMucfr4B+62eipXm335hOs23LTnO62W7Psn3qvU2k= 30 | github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= 31 | github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= 32 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 33 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 34 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 35 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 36 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 37 | github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= 38 | github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= 39 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 40 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 41 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 42 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 43 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 44 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 45 | github.com/youpy/go-riff v0.1.0 h1:vZO/37nI4tIET8tQI0Qn0Y79qQh99aEpponTPiPut7k= 46 | github.com/youpy/go-riff v0.1.0/go.mod h1:83nxdDV4Z9RzrTut9losK7ve4hUnxUR8ASSz4BsKXwQ= 47 | github.com/youpy/go-wav v0.3.2 h1:NLM8L/7yZ0Bntadw/0h95OyUsen+DQIVf9gay+SUsMU= 48 | github.com/youpy/go-wav v0.3.2/go.mod h1:0FCieAXAeSdcxFfwLpRuEo0PFmAoc+8NU34h7TUvk50= 49 | github.com/zaf/g711 v0.0.0-20190814101024-76a4a538f52b/go.mod h1:T2h1zV50R/q0CVYnsQOQ6L7P4a2ZxH47ixWcMXFGyx8= 50 | github.com/zaf/g711 v1.4.0 h1:XZYkjjiAg9QTBnHqEg37m2I9q3IIDv5JRYXs2N8ma7c= 51 | github.com/zaf/g711 v1.4.0/go.mod h1:eCDXt3dSp/kYYAoooba7ukD/Q75jvAaS4WOMr0l1Roo= 52 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 53 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 54 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 55 | google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 56 | google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 57 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 58 | gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM= 59 | gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g= 60 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 61 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 62 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 63 | gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= 64 | gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= 65 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish new release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release-type: 7 | type: choice 8 | description: Select the release type 9 | required: true 10 | options: 11 | - patch 12 | - minor 13 | - major 14 | 15 | permissions: 16 | id-token: write 17 | contents: write 18 | 19 | jobs: 20 | ensure-changes: 21 | name: Ensure there are changes 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v6 27 | with: 28 | fetch-depth: 0 # Required to get all existing tags 29 | fetch-tags: true 30 | 31 | - name: Stop if there are no new changes 32 | run: | 33 | LATEST_TAG=$(git describe --tags --abbrev=0 --always) 34 | LAST_COMMIT=$(git rev-parse HEAD) 35 | LASTEST_TAG_COMMIT=$(git rev-list -n 1 $LATEST_TAG) 36 | if [ "$LASTEST_TAG_COMMIT" == "$LAST_COMMIT" ]; then 37 | echo "No new changes since the last tag. Stopping the workflow." 38 | exit 1 39 | fi 40 | exit 0 41 | 42 | build-macos: 43 | name: Build macOS 44 | runs-on: macos-latest 45 | needs: ensure-changes 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v6 50 | 51 | - name: Install deps 52 | # The x86_64 version is installed in /usr/local/bin/brew 53 | # We could remove the 2 commands that use shellenv and invoke brew like this: 54 | # arch -x86_64 /usr/local/bin/brew xxx 55 | run: | 56 | export HOMEBREW_NO_AUTO_UPDATE=1 57 | export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 58 | /usr/sbin/softwareupdate --install-rosetta --agree-to-license 59 | arch -x86_64 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 60 | (echo; echo 'eval "$(/usr/local/bin/brew shellenv)"') >> /Users/runner/.bash_profile 61 | eval "$(/usr/local/bin/brew shellenv)" 62 | arch -x86_64 brew install opus 63 | arch -x86_64 brew install pkg-config 64 | OPUS_VERSION=$(arch -x86_64 brew info --json opus | jq -r '.[].versions.stable') 65 | cp /usr/local/Cellar/opus/$OPUS_VERSION/lib/libopus.0.dylib dist/bin/darwin-x64 66 | 67 | - name: Setup Go 68 | uses: actions/setup-go@v6 69 | with: 70 | go-version-file: "go.mod" 71 | 72 | - name: Build 73 | run: | 74 | make build-darwin 75 | cd dist/bin 76 | zip -r darwin-x64.zip darwin-x64 77 | 78 | - name: Upload archive 79 | uses: actions/upload-artifact@v6 80 | with: 81 | name: darwin-x64 82 | path: dist/bin/darwin-x64.zip 83 | 84 | build-linux: 85 | name: Build Linux 86 | runs-on: ubuntu-latest 87 | needs: ensure-changes 88 | 89 | steps: 90 | - name: Checkout repository 91 | uses: actions/checkout@v6 92 | 93 | - name: Install deps 94 | run: | 95 | sudo apt install pkg-config libopus-dev 96 | cp /usr/lib/x86_64-linux-gnu/libopus.so.0 dist/bin/linux-x64 97 | 98 | - name: Setup Go 99 | uses: actions/setup-go@v6 100 | with: 101 | go-version-file: "go.mod" 102 | 103 | - name: Build 104 | run: | 105 | make build-linux 106 | cd dist/bin 107 | zip -r linux-x64.zip linux-x64 108 | 109 | - name: Upload archive 110 | uses: actions/upload-artifact@v6 111 | with: 112 | name: linux-x64 113 | path: dist/bin/linux-x64.zip 114 | 115 | build-windows: 116 | name: Build Windows 117 | runs-on: windows-latest 118 | needs: ensure-changes 119 | 120 | steps: 121 | - name: Checkout repository 122 | uses: actions/checkout@v6 123 | 124 | - name: Setup MSBuild 125 | uses: microsoft/setup-msbuild@v2 126 | 127 | - uses: msys2/setup-msys2@v2 128 | id: msys2 129 | with: 130 | msystem: MINGW32 131 | install: >- 132 | mingw-w64-i686-gcc 133 | mingw-w64-i686-pkg-config 134 | make 135 | 136 | - name: Setup Go 137 | uses: actions/setup-go@v6 138 | with: 139 | go-version-file: "go.mod" 140 | 141 | - name: Install deps 142 | run: | 143 | choco install zip 144 | 145 | - name: Build Opus 146 | run: | 147 | git clone https://github.com/xiph/opus 148 | cd opus 149 | git fetch --tags 150 | git checkout tags/v1.5.2 151 | mkdir build 152 | cd build 153 | cmake -G "Visual Studio 17 2022" -A Win32 -DCMAKE_BUILD_TYPE:STRING=Release -DBUILD_SHARED_LIBS=ON .. 154 | msbuild opus.sln /p:Configuration=Release /p:Platform=Win32 155 | cmake --build . --config Release 156 | cd ${{ github.workspace }} 157 | cp opus.pc.example opus.pc 158 | sed -i 's|^prefix=.*|prefix=${{ github.workspace }}/opus|' opus.pc 159 | 160 | - name: Build 161 | run: | 162 | cp opus/build/Release/opus.dll dist/bin/win32-x64 163 | make build-windows 164 | env: 165 | PATH: ${{ steps.msys2.outputs.msys2-location }}/mingw32/bin;${{ steps.msys2.outputs.msys2-location }}/usr/bin;${{ env.PATH }} 166 | CPATH: ${{ github.workspace }}/opus/include 167 | LIBRARY_PATH: ${{ github.workspace }}/opus/build/Release 168 | 169 | - name: Create archive 170 | run: | 171 | cd dist/bin 172 | zip -r win32-x64.zip win32-x64 173 | 174 | - name: Upload archive 175 | uses: actions/upload-artifact@v6 176 | with: 177 | name: win32-x64 178 | path: dist/bin/win32-x64.zip 179 | 180 | publish: 181 | name: Publish release 182 | runs-on: ubuntu-latest 183 | needs: [build-macos, build-linux, build-windows] 184 | steps: 185 | - name: Checkout repository 186 | uses: actions/checkout@v6 187 | with: 188 | fetch-depth: 0 # Required to get all existing tags 189 | fetch-tags: true 190 | ssh-key: ${{ secrets.SSH_KEY }} 191 | 192 | - name: Setup node 193 | uses: actions/setup-node@v6 194 | 195 | # Use a recent npm version that supports trusted publishing 196 | - name: Install npm 197 | run: | 198 | sudo npm install -g npm@11.7.0 199 | 200 | - name: Bump version 201 | run: | 202 | git config --global user.name 'github-actions[bot]' 203 | git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com' 204 | npm version ${{ github.event.inputs.release-type }} --no-git-tag-version --tag-version-prefix="" 205 | node -p "require('./package.json').version" > /tmp/NEW_VERSION 206 | git add package.json 207 | git commit -m "chore: version $(cat /tmp/NEW_VERSION)" 208 | git tag "v$(cat /tmp/NEW_VERSION)" 209 | 210 | - name: Download artifacts 211 | uses: actions/download-artifact@v7 212 | 213 | - name: Extract and copy artifacts for NPM 214 | run: | 215 | mkdir -p dist/bin 216 | unzip -o darwin-x64/darwin-x64.zip -d dist/bin 217 | unzip -o linux-x64/linux-x64.zip -d dist/bin 218 | unzip -o win32-x64/win32-x64.zip -d dist/bin 219 | 220 | - name: Create release 221 | uses: softprops/action-gh-release@v2 222 | with: 223 | draft: true 224 | files: | 225 | darwin-x64/darwin-x64.zip 226 | linux-x64/linux-x64.zip 227 | win32-x64/win32-x64.zip 228 | env: 229 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 230 | 231 | - name: Push changes 232 | run: | 233 | git config --global user.name 'github-actions[bot]' 234 | git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com' 235 | npm publish 236 | git push origin main --tags 237 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Counter-Strike voice extractor 2 | 3 | CLI to export players' voices from CSGO/CS2 demos into WAV files. 4 | 5 | > [!WARNING] 6 | > **Valve Matchmaking demos do not contain voice audio data, hence there is nothing to extract from MM demos.** 7 | 8 | ## Installation 9 | 10 | Download the latest release for your OS from [GitHub](https://github.com/akiver/csgo-voice-extractor/releases/latest). 11 | 12 | ## Usage 13 | 14 | ### Mode 15 | 16 | The program can export voices in 3 different modes: 17 | 18 | 1. **Split compact**: extracts and concatenates all of each player's voice segments into separate WAV files. Each player will have their own WAV file containing only their voice data (without silence), and the files will be named after the player's Steam ID. This is the default mode. 19 | 2. **Split full**: extracts players' voices into separate WAV files that have the same duration as the demo file. Each player will have their own WAV file with voice segments placed at their original timestamps, and the files will be named after the player's Steam ID. 20 | 3. **Single full**: extracts and merges all players' voices into a single WAV file that has the same duration as the demo file, preserving the original timing of all voice communications. 21 | 22 | To change the mode, you have to set the `-mode` argument. The possible values are: 23 | 24 | - `split-compact` (default) 25 | - `split-full` 26 | - `single-full` 27 | 28 | ### Windows 29 | 30 | ```bash 31 | csgove.exe demoPaths... [-output] 32 | ``` 33 | 34 | By default `.dll` files are expected to be in the same directory as the executable. 35 | You can change it by setting the `LD_LIBRARY_PATH` environment variable. Example: 36 | 37 | ```bash 38 | LD_LIBRARY_PATH="C:\Users\username\Desktop" csgove.exe 39 | ``` 40 | 41 | ### macOS 42 | 43 | > [!CAUTION] 44 | > The environment variable `DYLD_LIBRARY_PATH` must be set before invoking the program and point to the location of the `.dylib` files! 45 | 46 | ```bash 47 | DYLD_LIBRARY_PATH=. csgove demoPaths... [-output] 48 | ``` 49 | 50 | ### Linux 51 | 52 | > [!CAUTION] 53 | > The environment variable `LD_LIBRARY_PATH` must be set before invoking the program and point to the location of the `.so` files! 54 | 55 | ```bash 56 | LD_LIBRARY_PATH=. csgove demoPaths... [-output] 57 | ``` 58 | 59 | ### Options 60 | 61 | `-output ` 62 | 63 | Folder location where audio files will be written. Current working directory by default. 64 | 65 | `-mode ` 66 | 67 | Output mode that determines how the voices are extracted and saved: 68 | 69 | - `split-compact` (default): separate files per player, without silence 70 | - `split-full`: separate files per player, with demo-length silence 71 | - `single-full`: single merged file with all players' voices 72 | 73 | `-steam-ids ` 74 | 75 | Comma-separated list of Steam IDs 64 to extract voices for. If not provided, voices for all players will be extracted. 76 | 77 | `-exit-on-first-error` 78 | 79 | Stop the program at the first error encountered. By default, the program will continue to the next demo to process if an error occurs. 80 | 81 | ### Examples 82 | 83 | Extract voices from the demo `myDemo.dem` in the current directory: 84 | 85 | ```bash 86 | csgove myDemo.dem 87 | ``` 88 | 89 | Extract voices from multiple demos using absolute or relative paths: 90 | 91 | ```bash 92 | csgove myDemo1.dem ../myDemo2.dem "C:\Users\username\Desktop\myDemo3.dem" 93 | ``` 94 | 95 | Change the output location: 96 | 97 | ```bash 98 | csgove -output "C:\Users\username\Desktop\output" myDemo.dem 99 | ``` 100 | 101 | Extract all voices into a single merged file: 102 | 103 | ```bash 104 | csgove -mode single-full myDemo.dem 105 | ``` 106 | 107 | Extract only voices of specific players: 108 | 109 | ```bash 110 | csgove -steam-ids 76561198123456789,76561198123456780 myDemo.dem 111 | ``` 112 | 113 | ## Developing 114 | 115 | ### Requirements 116 | 117 | - [Go](https://go.dev/) 118 | - [GCC](https://gcc.gnu.org/) 119 | - [Chocolatey](https://chocolatey.org/) (Windows only) 120 | 121 | _Debugging is easier on macOS/Linux **64-bit**, see warnings below._ 122 | 123 | ### Windows 124 | 125 | _Because the CSGO audio library is a 32-bit DLL, you need a 32-bit `GCC` and set the Go env variable `GOARCH=386` to build the program._ 126 | 127 | > [!IMPORTANT] 128 | > Use a unix like shell such as [Git Bash](https://git-scm.com/), it will not work with `cmd.exe`! 129 | 130 | > [!WARNING] 131 | > The `$GCC_PATH` variable in the following steps is the path where `gcc.exe` is located. 132 | > By default, it's `C:\TDM-GCC-64\bin` when using [TDM-GCC](https://jmeubank.github.io/tdm-gcc/) (highly recommended). 133 | 134 | 1. Install `GCC` for Windows, [TDM-GCC](https://jmeubank.github.io/tdm-gcc/) is recommended because it handles both 32-bit and 64-bit when running `go build`. 135 | If you use [MSYS2](https://www.msys2.org/), it's important to install the 32-bit version (`pacman -S mingw-w64-i686-gcc`). 136 | 2. Install `pkg-config` using [chocolatey](https://chocolatey.org/) by running `choco install pkgconfiglite`. 137 | It's **highly recommended** to use `choco` otherwise you would have to build `pkg-config` and copy/paste the `pkg-config.exe` binary in your `$GCC_PATH`. 138 | 3. Download the source code of [Opus](https://opus-codec.org/downloads/) 139 | 4. Extract the archive, rename the folder to `opus` and place it in the project's root folder 140 | 5. `mkdir build && cd build` 141 | 6. `cmake -G "Visual Studio 17 2022" -A Win32 -DCMAKE_BUILD_TYPE:STRING=Release -DBUILD_SHARED_LIBS=ON ..` 142 | 7. Build the `Release` configuration for `Win32` (**not `x64`** - it's important to build the 32-bit version!) 143 | 8. Copy/paste the `opus.dll` file in `$GCC_PATH` and `dist/bin/win32-x64` 144 | 9. Copy/paste the C header files located inside the `include` folder file in `$GCC_PATH\include\opus` (create the folders if needed) 145 | 10. Copy/paste the `opus.pc.example` to `opus.pc` file and edit the `prefix` variable to match your `GCC` installation path **if necessary**. 146 | 11. `PKG_CONFIG_PATH=$(realpath .) LD_LIBRARY_PATH=dist/bin/win32-x64 CGO_ENABLED=1 GOARCH=386 go run -tags nolibopusfile .` 147 | 148 | > [!WARNING] 149 | > Because the Go debugger doesn't support Windows 32-bit and the CSGO lib is a 32-bit DLL, you will not be able to run the Go debugger. 150 | > If you want to be able to run the debugger for the **Go part only**, you could comment on lines that involve `C/CGO` calls. 151 | 152 | ### macOS 153 | 154 | > [!IMPORTANT] 155 | > On macOS `ARM64`, the `x64` version of Homebrew must be installed! 156 | > You can install it by adding `arch -x86_64` before the official command to install Homebrew (`arch -x86_64 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`) 157 | 158 | 1. Install [Homebrew](https://brew.sh) **x64 version** 159 | 2. `arch -x86_64 /usr/local/bin/brew install opus` 160 | 3. `arch -x86_64 /usr/local/bin/brew install pkg-config` 161 | 4. `cp /usr/local/Cellar/opus/1.6/lib/libopus.0.dylib dist/bin/darwin-x64` (`arch -x86_64 brew info opus` to get the path) 162 | 5. `DYLD_LIBRARY_PATH=dist/bin/darwin-x64 CGO_ENABLED=1 GOARCH=amd64 go run -tags nolibopusfile .` 163 | 164 | > [!WARNING] 165 | > On macOS ARM64, the Go debugger breakpoints will not work because the executable must target amd64 but your OS is ARM64. 166 | 167 | ### Linux 168 | 169 | 1. `sudo apt install pkg-config libopus-dev` 170 | 2. `cp /usr/lib/x86_64-linux-gnu/libopus.so.0 dist/bin/linux-x64` (you may need to change the path depending on your distro) 171 | 3. `LD_LIBRARY_PATH=dist/bin/linux-x64 CGO_ENABLED=1 GOARCH=amd64 go run -tags nolibopusfile .` 172 | 173 | ## Building 174 | 175 | ### Windows 176 | 177 | `make build-windows` 178 | 179 | ### macOS 180 | 181 | `make build-darwin` 182 | 183 | ### Linux 184 | 185 | `make build-linux` 186 | 187 | ## Credits 188 | 189 | Thanks to [@saul](https://github.com/saul) and [@ericek111](https://github.com/ericek111) for their [CSGO investigation](https://github.com/saul/demofile/issues/83#issuecomment-1207437098). 190 | Thanks to [@DandrewsDev](https://github.com/DandrewsDev) for his work on [CS2 voice data extraction](https://github.com/DandrewsDev/CS2VoiceData). 191 | 192 | ## License 193 | 194 | [MIT](https://github.com/akiver/csgo-voice-extractor/blob/main/LICENSE) 195 | -------------------------------------------------------------------------------- /csgo/extractor.go: -------------------------------------------------------------------------------- 1 | package csgo 2 | 3 | // #cgo CFLAGS: -Wall -g 4 | // #include 5 | // #include "decoder.h" 6 | import "C" 7 | import ( 8 | "errors" 9 | "fmt" 10 | "math" 11 | "os" 12 | "path/filepath" 13 | "slices" 14 | "strings" 15 | "unsafe" 16 | 17 | "github.com/akiver/csgo-voice-extractor/common" 18 | "github.com/go-audio/audio" 19 | goWav "github.com/go-audio/wav" 20 | dem "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs" 21 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/msg" 22 | "github.com/youpy/go-wav" 23 | "google.golang.org/protobuf/proto" 24 | ) 25 | 26 | const ( 27 | SampleRate = 22050 28 | BytesPerSample = 2 // 16-bit PCM 29 | PacketSize = 64 // size of a single encoded voice packet in bytes 30 | FrameSize = 512 // number of samples per frame after decoding 31 | ) 32 | 33 | func createTempFile(prefix string) (string, error) { 34 | tmpFile, err := os.CreateTemp("", prefix) 35 | if err != nil { 36 | common.HandleError(common.Error{ 37 | Message: fmt.Sprintf("Failed to create temporary file: %s", err), 38 | ExitCode: common.DecodingError, 39 | }) 40 | return "", err 41 | } 42 | 43 | filePath := tmpFile.Name() 44 | tmpFile.Close() 45 | 46 | return filePath, nil 47 | } 48 | 49 | func buildPlayerWavFilePath(playerID string, demoName string, outputPath string) string { 50 | return filepath.Join(outputPath, fmt.Sprintf("%s_%s.wav", demoName, playerID)) 51 | } 52 | 53 | func getSegments(file *os.File, steamIDs []string) (map[string][]common.VoiceSegment, float64, error) { 54 | var segments = map[string][]common.VoiceSegment{} 55 | 56 | parserConfig := dem.DefaultParserConfig 57 | parserConfig.AdditionalNetMessageCreators = map[int]dem.NetMessageCreator{ 58 | int(msg.SVC_Messages_svc_VoiceData): func() proto.Message { 59 | return new(msg.CSVCMsg_VoiceData) 60 | }, 61 | int(msg.SVC_Messages_svc_VoiceInit): func() proto.Message { 62 | return new(msg.CSVCMsg_VoiceInit) 63 | }, 64 | } 65 | 66 | parser := dem.NewParserWithConfig(file, parserConfig) 67 | defer parser.Close() 68 | 69 | parser.RegisterNetMessageHandler(func(m *msg.CSVCMsg_VoiceInit) { 70 | if m.GetCodec() != "vaudio_celt" || m.GetQuality() != 5 || m.GetVersion() != 3 { 71 | common.UnsupportedCodecError = &common.UnsupportedCodec{ 72 | Name: m.GetCodec(), 73 | Quality: m.GetQuality(), 74 | Version: m.GetVersion(), 75 | } 76 | parser.Cancel() 77 | } 78 | }) 79 | 80 | parser.RegisterNetMessageHandler(func(m *msg.CSVCMsg_VoiceData) { 81 | steamID := m.GetXuid() 82 | if len(steamIDs) > 0 && !slices.Contains(steamIDs, fmt.Sprintf("%d", steamID)) { 83 | return 84 | } 85 | 86 | playerID := common.GetPlayerID(parser, steamID) 87 | if playerID == "" { 88 | return 89 | } 90 | 91 | if segments[playerID] == nil { 92 | segments[playerID] = make([]common.VoiceSegment, 0) 93 | } 94 | segments[playerID] = append(segments[playerID], common.VoiceSegment{ 95 | Data: m.GetVoiceData(), 96 | Timestamp: parser.CurrentTime().Seconds(), 97 | }) 98 | }) 99 | 100 | err := parser.ParseToEnd() 101 | durationSeconds := parser.CurrentTime().Seconds() 102 | 103 | return segments, durationSeconds, err 104 | } 105 | 106 | func decodeVoiceData(data []byte) ([]byte, bool) { 107 | if len(data) == 0 { 108 | return nil, false 109 | } 110 | 111 | outputSamples := (len(data) / PacketSize) * FrameSize 112 | if outputSamples == 0 { 113 | outputSamples = FrameSize // fallback for very small packets 114 | } 115 | outputSize := outputSamples * 2 // 2 bytes per sample 116 | pcm := make([]byte, outputSize) 117 | 118 | cData := (*C.uchar)(unsafe.Pointer(&data[0])) 119 | cDataSize := C.int(len(data)) 120 | cPcm := (*C.char)(unsafe.Pointer(&pcm[0])) 121 | cPcmSize := C.int(outputSize) 122 | 123 | written := C.Decode(cDataSize, cData, cPcm, cPcmSize) 124 | if written <= 0 { 125 | return nil, false 126 | } 127 | 128 | return pcm[:written], true 129 | } 130 | 131 | func generateAudioFileWithMergedVoices(segmentsPerPlayer map[string][]common.VoiceSegment, durationSeconds float64, demoName string, outputPath string) { 132 | wavFilePath := filepath.Join(outputPath, demoName+".wav") 133 | wavFile, err := common.CreateWavFile(wavFilePath) 134 | if err != nil { 135 | return 136 | } 137 | defer wavFile.Close() 138 | 139 | totalSamples := int(durationSeconds * float64(SampleRate)) 140 | var numChannels uint16 = 1 141 | var bitsPerSample uint16 = 16 142 | writer := wav.NewWriter(wavFile, uint32(totalSamples), numChannels, SampleRate, bitsPerSample) 143 | 144 | type VoiceSegmentInfo struct { 145 | StartPosition int 146 | Data []byte 147 | Length int 148 | } 149 | 150 | voiceSegments := make([]VoiceSegmentInfo, 0) 151 | for _, segments := range segmentsPerPlayer { 152 | var previousEndPosition = 0 153 | for _, segment := range segments { 154 | startSample := int(segment.Timestamp * float64(SampleRate)) 155 | startPosition := startSample * BytesPerSample 156 | 157 | if startPosition < previousEndPosition { 158 | startPosition = previousEndPosition 159 | } 160 | 161 | if startPosition >= totalSamples*BytesPerSample { 162 | fmt.Printf("Warning: Voice segment at %f seconds exceeds demo duration\n", segment.Timestamp) 163 | continue 164 | } 165 | 166 | samples, ok := decodeVoiceData(segment.Data) 167 | if !ok { 168 | common.HandleError(common.NewDecodingError("Failed to decode voice data", nil)) 169 | continue 170 | } 171 | 172 | voiceSegments = append(voiceSegments, VoiceSegmentInfo{ 173 | StartPosition: startPosition, 174 | Data: samples, 175 | Length: len(samples), 176 | }) 177 | 178 | previousEndPosition = startPosition + len(samples) 179 | } 180 | } 181 | 182 | // process in small chunks to avoid high memory usage 183 | const chunkSize = 8192 * BytesPerSample 184 | for chunkStart := 0; chunkStart < totalSamples*BytesPerSample; chunkStart += chunkSize { 185 | chunkEnd := chunkStart + chunkSize 186 | if chunkEnd > totalSamples*BytesPerSample { 187 | chunkEnd = totalSamples * BytesPerSample 188 | } 189 | 190 | chunkLength := chunkEnd - chunkStart 191 | mixedChunk := make([]int32, chunkLength/BytesPerSample) 192 | activeSources := make([]int, chunkLength/BytesPerSample) 193 | 194 | // find segments that overlap with the current chunk 195 | for _, segment := range voiceSegments { 196 | segmentEnd := segment.StartPosition + segment.Length 197 | // check if this segment does not overlap with the current chunk 198 | if segmentEnd <= chunkStart || segment.StartPosition >= chunkEnd { 199 | continue 200 | } 201 | 202 | // calculate the start and end of the overlap 203 | overlapStart := segment.StartPosition 204 | if overlapStart < chunkStart { 205 | overlapStart = chunkStart 206 | } 207 | overlapEnd := segmentEnd 208 | if overlapEnd > chunkEnd { 209 | overlapEnd = chunkEnd 210 | } 211 | 212 | // add samples in the chunk and track active sources (players talking at the same time) 213 | for i := overlapStart; i < overlapEnd; i += BytesPerSample { 214 | sampleIndex := (i - chunkStart) / BytesPerSample 215 | sampleStartPosition := i - segment.StartPosition 216 | 217 | if sampleStartPosition >= 0 && sampleStartPosition < segment.Length-1 { 218 | sample := int32(int16(uint16(segment.Data[sampleStartPosition]) | uint16(segment.Data[sampleStartPosition+1])<<8)) 219 | if sample != 0 { // ignore silence 220 | mixedChunk[sampleIndex] += sample 221 | activeSources[sampleIndex]++ 222 | } 223 | } 224 | } 225 | } 226 | 227 | // normalize and mix samples in the chunk 228 | maxSampleValue := int32(1) 229 | chunkBytes := make([]byte, chunkLength) 230 | for i, sample := range mixedChunk { 231 | if sample == 0 || activeSources[i] == 0 { 232 | // write silence 233 | chunkBytes[i*BytesPerSample] = 0 234 | chunkBytes[i*BytesPerSample+1] = 0 235 | continue 236 | } 237 | 238 | // normalize the sample if several players are talking at the same time 239 | mixCoefficient := 1.0 240 | if activeSources[i] > 1 { 241 | mixCoefficient = 1.0 / math.Sqrt(float64(activeSources[i])) 242 | } 243 | 244 | mixedSample := float64(sample) * mixCoefficient 245 | if int32(math.Abs(mixedSample)) > maxSampleValue { 246 | maxSampleValue = int32(math.Abs(mixedSample)) 247 | } 248 | 249 | // clip to int16 range because WAV format requires 16-bit samples 250 | if mixedSample > math.MaxInt16 { 251 | mixedSample = math.MaxInt16 252 | } else if mixedSample < -math.MaxInt16 { 253 | mixedSample = -math.MaxInt16 254 | } 255 | 256 | // convert back to bytes 257 | mixedInt16 := int16(mixedSample) 258 | chunkBytes[i*BytesPerSample] = byte(mixedInt16) 259 | chunkBytes[i*BytesPerSample+1] = byte(mixedInt16 >> 8) 260 | } 261 | 262 | _, err = writer.Write(chunkBytes) 263 | if err != nil { 264 | common.HandleError(common.Error{ 265 | Message: "Couldn't write WAV file", 266 | Err: err, 267 | ExitCode: common.WavFileCreationError, 268 | }) 269 | } 270 | } 271 | } 272 | 273 | func generateAudioFilesWithDemoLength(segmentsPerPlayer map[string][]common.VoiceSegment, durationSeconds float64, demoName string, outputPath string) { 274 | for playerID, segments := range segmentsPerPlayer { 275 | wavFilePath := buildPlayerWavFilePath(playerID, demoName, outputPath) 276 | totalSamples := int(durationSeconds * float64(SampleRate)) 277 | 278 | wavFile, err := common.CreateWavFile(wavFilePath) 279 | if err != nil { 280 | return 281 | } 282 | defer wavFile.Close() 283 | 284 | var numChannels uint16 = 1 285 | var bitsPerSample uint16 = 16 286 | writer := wav.NewWriter(wavFile, uint32(totalSamples), numChannels, SampleRate, bitsPerSample) 287 | 288 | chunkSize := 8192 * BytesPerSample 289 | chunkBuffer := make([]byte, chunkSize) 290 | 291 | previousEndPosition := 0 292 | segmentIndex := 0 293 | for position := 0; position < totalSamples*BytesPerSample; position += chunkSize { 294 | // clear the chunk buffer 295 | for i := range chunkBuffer { 296 | chunkBuffer[i] = 0 297 | } 298 | 299 | currentChunkEnd := position + chunkSize 300 | if currentChunkEnd > totalSamples*BytesPerSample { 301 | currentChunkEnd = totalSamples * BytesPerSample 302 | chunkBuffer = chunkBuffer[:currentChunkEnd-position] 303 | } 304 | 305 | // find segments that overlap with the current chunk 306 | for segmentIndex < len(segments) { 307 | segment := segments[segmentIndex] 308 | startSample := int(segment.Timestamp * float64(SampleRate)) 309 | startPosition := startSample * BytesPerSample 310 | 311 | if startPosition < previousEndPosition { 312 | startPosition = previousEndPosition 313 | } 314 | 315 | if startPosition >= currentChunkEnd { 316 | break 317 | } 318 | 319 | samples, ok := decodeVoiceData(segment.Data) 320 | if !ok { 321 | segmentIndex++ 322 | continue 323 | } 324 | 325 | segmentEnd := startPosition + len(samples) 326 | 327 | // calculate the start and end of the overlap 328 | overlapStart := startPosition 329 | if overlapStart < position { 330 | overlapStart = position 331 | } 332 | overlapEnd := segmentEnd 333 | if overlapEnd > currentChunkEnd { 334 | overlapEnd = currentChunkEnd 335 | } 336 | 337 | // copy overlapping data to the chunk buffer 338 | if overlapStart < overlapEnd { 339 | srcOffset := overlapStart - startPosition 340 | destOffset := overlapStart - position 341 | copyLength := overlapEnd - overlapStart 342 | sampleCount := len(samples) 343 | chunkCount := len(chunkBuffer) 344 | 345 | if srcOffset >= 0 && srcOffset < sampleCount && 346 | destOffset >= 0 && destOffset < chunkCount && 347 | srcOffset+copyLength <= sampleCount && 348 | destOffset+copyLength <= chunkCount { 349 | copy(chunkBuffer[destOffset:destOffset+copyLength], samples[srcOffset:srcOffset+copyLength]) 350 | } 351 | } 352 | 353 | previousEndPosition = segmentEnd 354 | if segmentEnd > currentChunkEnd { 355 | break 356 | } 357 | segmentIndex++ 358 | } 359 | 360 | _, err = writer.Write(chunkBuffer) 361 | if err != nil { 362 | common.HandleError(common.Error{ 363 | Message: "Couldn't write WAV file", 364 | Err: err, 365 | ExitCode: common.WavFileCreationError, 366 | }) 367 | return 368 | } 369 | } 370 | } 371 | } 372 | 373 | func generateAudioFilesWithCompactLength(segmentsPerPlayer map[string][]common.VoiceSegment, demoName string, options common.ExtractOptions) { 374 | for playerID, playerSegments := range segmentsPerPlayer { 375 | if len(playerSegments) == 0 { 376 | continue 377 | } 378 | 379 | wavFilePath := buildPlayerWavFilePath(playerID, demoName, options.OutputPath) 380 | outFile, err := common.CreateWavFile(wavFilePath) 381 | if err != nil { 382 | continue 383 | } 384 | defer outFile.Close() 385 | 386 | enc := goWav.NewEncoder(outFile, SampleRate, 16, 1, 1) 387 | defer enc.Close() 388 | 389 | for _, segment := range playerSegments { 390 | samples, ok := decodeVoiceData(segment.Data) 391 | if !ok { 392 | continue 393 | } 394 | 395 | if len(samples) > 0 { 396 | // convert to ints for WAV encoding 397 | numSamples := len(samples) / 2 398 | intSamples := make([]int, numSamples) 399 | for i := 0; i < numSamples; i++ { 400 | sample := int16(uint16(samples[i*2]) | uint16(samples[i*2+1])<<8) 401 | intSamples[i] = int(sample) 402 | } 403 | 404 | buf := &audio.IntBuffer{ 405 | Data: intSamples, 406 | Format: &audio.Format{ 407 | SampleRate: SampleRate, 408 | NumChannels: 1, 409 | }, 410 | } 411 | 412 | if err := enc.Write(buf); err != nil { 413 | common.HandleError(common.Error{ 414 | Message: "Couldn't write WAV file", 415 | Err: err, 416 | ExitCode: common.WavFileCreationError, 417 | }) 418 | } 419 | } 420 | } 421 | } 422 | } 423 | 424 | func Extract(options common.ExtractOptions) { 425 | common.AssertLibraryFilesExist() 426 | 427 | cLibrariesPath := C.CString(common.LibrariesPath) 428 | initAudioLibResult := C.Init(cLibrariesPath) 429 | C.free(unsafe.Pointer(cLibrariesPath)) 430 | 431 | if initAudioLibResult != 0 { 432 | common.HandleError(common.Error{ 433 | Message: "Failed to initialize CSGO audio decoder", 434 | ExitCode: common.LoadCsgoLibError, 435 | }) 436 | return 437 | } 438 | 439 | segmentsPerPlayer, durationSeconds, err := getSegments(options.File, options.SteamIDs) 440 | common.AssertCodecIsSupported() 441 | 442 | demoPath := options.DemoPath 443 | isCorruptedDemo := errors.Is(err, dem.ErrUnexpectedEndOfDemo) 444 | isCanceled := errors.Is(err, dem.ErrCancelled) 445 | if err != nil && !isCorruptedDemo && !isCanceled { 446 | common.HandleError(common.Error{ 447 | Message: fmt.Sprintf("Failed to parse demo: %s\n", demoPath), 448 | Err: err, 449 | ExitCode: common.ParsingError, 450 | }) 451 | return 452 | } 453 | 454 | if isCanceled { 455 | return 456 | } 457 | 458 | if len(segmentsPerPlayer) == 0 { 459 | common.HandleError(common.Error{ 460 | Message: fmt.Sprintf("No voice data found in demo %s\n", demoPath), 461 | ExitCode: common.NoVoiceDataFound, 462 | }) 463 | return 464 | } 465 | 466 | fmt.Println("Parsing done, generating audio files...") 467 | demoName := strings.TrimSuffix(filepath.Base(demoPath), filepath.Ext(demoPath)) 468 | if options.Mode == common.ModeSingleFull { 469 | generateAudioFileWithMergedVoices(segmentsPerPlayer, durationSeconds, demoName, options.OutputPath) 470 | } else if options.Mode == common.ModeSplitFull { 471 | generateAudioFilesWithDemoLength(segmentsPerPlayer, durationSeconds, demoName, options.OutputPath) 472 | } else { 473 | generateAudioFilesWithCompactLength(segmentsPerPlayer, demoName, options) 474 | } 475 | } 476 | -------------------------------------------------------------------------------- /cs2/extractor.go: -------------------------------------------------------------------------------- 1 | package cs2 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math" 7 | "os" 8 | "path/filepath" 9 | "slices" 10 | "strings" 11 | 12 | "github.com/akiver/csgo-voice-extractor/common" 13 | "github.com/go-audio/audio" 14 | "github.com/go-audio/wav" 15 | dem "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs" 16 | "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/msgs2" 17 | "gopkg.in/hraban/opus.v2" 18 | ) 19 | 20 | const ( 21 | // The samples rate could be retrieved from the VoiceData net message but it's always 48000 for Opus and 24000 for 22 | // Steam Voice and is single channel. 23 | opusSampleRate = 48000 24 | steamSampleRate = 24000 25 | ) 26 | 27 | func buildPlayerWavFileName(outputPath string, demoName string, playerID string) string { 28 | fileName := fmt.Sprintf("%s_%s.wav", demoName, playerID) 29 | 30 | return filepath.Join(outputPath, fileName) 31 | } 32 | 33 | func getFormatSampleRate(format msgs2.VoiceDataFormatT) int { 34 | if format == msgs2.VoiceDataFormatT_VOICEDATA_FORMAT_OPUS { 35 | return opusSampleRate 36 | } 37 | 38 | return steamSampleRate 39 | } 40 | 41 | func samplesToInt32(samples []float32) []int { 42 | ints := make([]int, len(samples)) 43 | for i, v := range samples { 44 | ints[i] = int(v * math.MaxInt32) 45 | } 46 | 47 | return ints 48 | } 49 | 50 | func writeAudioToWav(enc *wav.Encoder, sampleRate int, data []int) error { 51 | buf := &audio.IntBuffer{ 52 | Data: data, 53 | Format: &audio.Format{ 54 | SampleRate: sampleRate, 55 | NumChannels: 1, 56 | }, 57 | } 58 | if err := enc.Write(buf); err != nil { 59 | common.HandleError(common.Error{ 60 | Message: "Couldn't write WAV file", 61 | Err: err, 62 | ExitCode: common.WavFileCreationError, 63 | }) 64 | } 65 | 66 | return nil 67 | } 68 | 69 | func writePCMToWav(pcmBuffer []int, sampleRate int, filePath string) { 70 | outFile, err := os.Create(filePath) 71 | if err != nil { 72 | common.HandleError(common.Error{ 73 | Message: "Couldn't write WAV file", 74 | Err: err, 75 | ExitCode: common.WavFileCreationError, 76 | }) 77 | return 78 | } 79 | defer outFile.Close() 80 | 81 | enc := wav.NewEncoder(outFile, sampleRate, 32, 1, 1) 82 | defer enc.Close() 83 | 84 | buf := &audio.IntBuffer{ 85 | Data: pcmBuffer, 86 | Format: &audio.Format{ 87 | SampleRate: sampleRate, 88 | NumChannels: 1, 89 | }, 90 | } 91 | 92 | if err := enc.Write(buf); err != nil { 93 | common.HandleError(common.Error{ 94 | Message: "Couldn't write WAV file", 95 | Err: err, 96 | ExitCode: common.WavFileCreationError, 97 | }) 98 | } 99 | } 100 | 101 | func generateAudioFilesWithDemoLength(segmentsPerPlayer map[string][]common.VoiceSegment, format msgs2.VoiceDataFormatT, durationSeconds float64, demoName string, outputPath string) { 102 | for playerID, segments := range segmentsPerPlayer { 103 | wavFilePath := buildPlayerWavFileName(outputPath, demoName, playerID) 104 | if format == msgs2.VoiceDataFormatT_VOICEDATA_FORMAT_OPUS { 105 | writeOpusVoiceSegmentsToWav(segments, wavFilePath, durationSeconds) 106 | } else { 107 | writeSteamVoiceSegmentsToWav(segments, wavFilePath, durationSeconds) 108 | } 109 | } 110 | } 111 | 112 | func generateAudioFilesWithCompactLength(segmentsPerPlayer map[string][]common.VoiceSegment, format msgs2.VoiceDataFormatT, demoName string, options common.ExtractOptions) { 113 | sampleRate := getFormatSampleRate(format) 114 | isOpusFormat := format == msgs2.VoiceDataFormatT_VOICEDATA_FORMAT_OPUS 115 | 116 | for playerID, segments := range segmentsPerPlayer { 117 | if len(segments) == 0 { 118 | continue 119 | } 120 | 121 | wavFilePath := buildPlayerWavFileName(options.OutputPath, demoName, playerID) 122 | outFile, err := common.CreateWavFile(wavFilePath) 123 | if err != nil { 124 | continue 125 | } 126 | defer outFile.Close() 127 | 128 | enc := wav.NewEncoder(outFile, sampleRate, 32, 1, 1) 129 | defer enc.Close() 130 | 131 | if isOpusFormat { 132 | decoder, err := NewOpusDecoder(sampleRate, 1) 133 | if err != nil { 134 | continue 135 | } 136 | 137 | for _, segment := range segments { 138 | samples, err := Decode(decoder, segment.Data) 139 | if err != nil { 140 | fmt.Println(err) 141 | continue 142 | } 143 | 144 | if len(samples) == 0 { 145 | continue 146 | } 147 | 148 | pcmBuffer := samplesToInt32(samples) 149 | err = writeAudioToWav(enc, sampleRate, pcmBuffer) 150 | if err != nil { 151 | continue 152 | } 153 | } 154 | } else { 155 | decoder, err := NewSteamDecoder(sampleRate, 1) 156 | if err != nil { 157 | continue 158 | } 159 | 160 | for _, segment := range segments { 161 | chunk, err := DecodeChunk(segment.Data) 162 | if err != nil || chunk == nil || len(chunk.Data) == 0 { 163 | continue 164 | } 165 | 166 | samples, err := decoder.Decode(chunk.Data) 167 | if err != nil { 168 | fmt.Println(err) 169 | continue 170 | } 171 | 172 | if len(samples) == 0 { 173 | continue 174 | } 175 | 176 | pcmBuffer := samplesToInt32(samples) 177 | writeAudioToWav(enc, sampleRate, pcmBuffer) 178 | } 179 | } 180 | } 181 | } 182 | 183 | func writeSteamVoiceSegmentsToWav(segments []common.VoiceSegment, fileName string, totalDuration float64) { 184 | decoder, err := NewSteamDecoder(steamSampleRate, 1) 185 | if err != nil { 186 | return 187 | } 188 | 189 | totalSamples := int(totalDuration * float64(steamSampleRate)) 190 | pcmBuffer := make([]int, totalSamples) 191 | var previousEndPos int = 0 192 | 193 | for _, segment := range segments { 194 | chunk, err := DecodeChunk(segment.Data) 195 | if err != nil { 196 | fmt.Println(err) 197 | continue 198 | } 199 | 200 | // Not silent frame 201 | if chunk != nil && len(chunk.Data) > 0 { 202 | samples, err := decoder.Decode(chunk.Data) 203 | 204 | if err != nil { 205 | common.HandleError(common.Error{ 206 | Message: "Failed to decode voice data", 207 | Err: err, 208 | ExitCode: common.DecodingError, 209 | }) 210 | } 211 | 212 | startPos := int(segment.Timestamp * float64(steamSampleRate)) 213 | if startPos < previousEndPos { 214 | startPos = previousEndPos 215 | } 216 | 217 | if startPos >= totalSamples { 218 | fmt.Printf("Warning: Voice segment at %f seconds exceeds demo duration\n", segment.Timestamp) 219 | continue 220 | } 221 | 222 | for i, sample := range samples { 223 | samplePos := startPos + i 224 | if samplePos >= totalSamples { 225 | break 226 | } 227 | pcmBuffer[samplePos] = int(sample * math.MaxInt32) 228 | } 229 | 230 | previousEndPos = startPos + len(samples) 231 | } 232 | } 233 | 234 | writePCMToWav(pcmBuffer, steamSampleRate, fileName) 235 | } 236 | 237 | func writeOpusVoiceSegmentsToWav(segments []common.VoiceSegment, fileName string, durationSeconds float64) { 238 | decoder, err := NewOpusDecoder(opusSampleRate, 1) 239 | if err != nil { 240 | return 241 | } 242 | 243 | totalSamples := int(durationSeconds * float64(opusSampleRate)) 244 | // store samples in a sparse map to avoid large memory usage 245 | samplesMap := make(map[int][]float32) 246 | // store start positions of segments that contain audio data 247 | activePositions := make([]int, 0) 248 | 249 | var previousEndPosition = 0 250 | // decode and store each voice segment in the sparse map 251 | for _, segment := range segments { 252 | samples, err := Decode(decoder, segment.Data) 253 | if err != nil { 254 | fmt.Println(err) 255 | continue 256 | } 257 | 258 | startPosition := int(segment.Timestamp * float64(opusSampleRate)) 259 | if startPosition < previousEndPosition { 260 | startPosition = previousEndPosition 261 | } 262 | 263 | if startPosition >= totalSamples { 264 | fmt.Printf("Warning: Voice segment at %f seconds exceeds demo duration\n", segment.Timestamp) 265 | continue 266 | } 267 | 268 | samplesMap[startPosition] = samples 269 | activePositions = append(activePositions, startPosition) 270 | previousEndPosition = startPosition + len(samples) 271 | } 272 | 273 | // no voice 274 | if len(activePositions) == 0 { 275 | return 276 | } 277 | 278 | outFile, err := common.CreateWavFile(fileName) 279 | if err != nil { 280 | return 281 | } 282 | defer outFile.Close() 283 | 284 | enc := wav.NewEncoder(outFile, opusSampleRate, 32, 1, 1) 285 | defer enc.Close() 286 | 287 | silenceBufferSize := 8192 // small buffer size for silence 288 | silenceBuffer := make([]int, silenceBufferSize) 289 | lastPosition := 0 290 | for _, startPosition := range activePositions { 291 | silenceLength := startPosition - lastPosition 292 | if silenceLength > 0 { 293 | // write silence in chunks to avoid large memory allocations 294 | for silenceLength > silenceBufferSize { 295 | err = writeAudioToWav(enc, opusSampleRate, silenceBuffer) 296 | if err != nil { 297 | return 298 | } 299 | silenceLength -= silenceBufferSize 300 | } 301 | 302 | // write remaining silence 303 | if silenceLength > 0 { 304 | err = writeAudioToWav(enc, opusSampleRate, silenceBuffer[:silenceLength]) 305 | if err != nil { 306 | return 307 | } 308 | } 309 | } 310 | 311 | // write the player's voice 312 | samples := samplesMap[startPosition] 313 | pcmBuffer := samplesToInt32(samples) 314 | err = writeAudioToWav(enc, opusSampleRate, pcmBuffer) 315 | if err != nil { 316 | return 317 | } 318 | lastPosition = startPosition + len(samples) 319 | } 320 | 321 | if lastPosition >= totalSamples { 322 | return 323 | } 324 | 325 | // write remaining silence at the end of the file 326 | remainingSilence := totalSamples - lastPosition 327 | for remainingSilence > silenceBufferSize { 328 | err = writeAudioToWav(enc, opusSampleRate, silenceBuffer) 329 | if err != nil { 330 | return 331 | } 332 | remainingSilence -= silenceBufferSize 333 | } 334 | 335 | if remainingSilence > 0 { 336 | writeAudioToWav(enc, opusSampleRate, silenceBuffer[:remainingSilence]) 337 | } 338 | } 339 | 340 | func generateAudioFileWithMergedVoices(voiceDataPerPlayer map[string][]common.VoiceSegment, format msgs2.VoiceDataFormatT, durationSeconds float64, demoName string, outputPath string) { 341 | var err error 342 | var opusDecoder *opus.Decoder 343 | var steamDecoder *SteamDecoder 344 | sampleRate := getFormatSampleRate(format) 345 | isOpusFormat := format == msgs2.VoiceDataFormatT_VOICEDATA_FORMAT_OPUS 346 | 347 | if isOpusFormat { 348 | opusDecoder, err = NewOpusDecoder(sampleRate, 1) 349 | if err != nil { 350 | return 351 | } 352 | } else { 353 | steamDecoder, err = NewSteamDecoder(sampleRate, 1) 354 | if err != nil { 355 | return 356 | } 357 | } 358 | 359 | totalSamples := int(durationSeconds * float64(sampleRate)) 360 | wavFilePath := filepath.Join(outputPath, demoName+".wav") 361 | outFile, err := common.CreateWavFile(wavFilePath) 362 | if err != nil { 363 | return 364 | } 365 | defer outFile.Close() 366 | 367 | enc := wav.NewEncoder(outFile, sampleRate, 32, 1, 1) 368 | defer enc.Close() 369 | 370 | type VoiceSegmentInfo struct { 371 | StartPosition int 372 | Samples []float32 373 | } 374 | 375 | // decode and store players' voice segments 376 | voiceSegments := make([]VoiceSegmentInfo, 0) 377 | for _, segments := range voiceDataPerPlayer { 378 | previousEndPosition := 0 379 | for _, segment := range segments { 380 | var pcm []float32 381 | if isOpusFormat { 382 | chunk, err := Decode(opusDecoder, segment.Data) 383 | if err != nil { 384 | fmt.Println(err) 385 | continue 386 | } 387 | pcm = chunk 388 | } else { 389 | chunk, err := DecodeChunk(segment.Data) 390 | if err != nil || chunk == nil || len(chunk.Data) == 0 { 391 | continue 392 | } 393 | samples, err := steamDecoder.Decode(chunk.Data) 394 | if err != nil { 395 | fmt.Println(err) 396 | continue 397 | } 398 | pcm = samples 399 | } 400 | 401 | if len(pcm) == 0 { 402 | continue 403 | } 404 | 405 | startPosition := int(segment.Timestamp * float64(sampleRate)) 406 | if startPosition < previousEndPosition { 407 | startPosition = previousEndPosition 408 | } 409 | 410 | if startPosition >= totalSamples { 411 | fmt.Printf("Warning: Voice segment at %f seconds exceeds demo duration\n", segment.Timestamp) 412 | continue 413 | } 414 | 415 | voiceSegments = append(voiceSegments, VoiceSegmentInfo{ 416 | StartPosition: startPosition, 417 | Samples: pcm, 418 | }) 419 | 420 | previousEndPosition = startPosition + len(pcm) 421 | } 422 | } 423 | 424 | // process in small chunks to avoid high memory usage 425 | const chunkSize = 8192 426 | for chunkStart := 0; chunkStart < totalSamples; chunkStart += chunkSize { 427 | chunkEnd := chunkStart + chunkSize 428 | if chunkEnd > totalSamples { 429 | chunkEnd = totalSamples 430 | } 431 | 432 | chunkLength := chunkEnd - chunkStart 433 | samples := make([]float32, chunkLength) 434 | activeSources := make([]int, chunkLength) 435 | 436 | // find segments that overlap with the current chunk 437 | for _, segment := range voiceSegments { 438 | segmentEnd := segment.StartPosition + len(segment.Samples) 439 | // check if this segment does not overlap with the current chunk 440 | if segmentEnd <= chunkStart || segment.StartPosition >= chunkEnd { 441 | continue 442 | } 443 | 444 | // calculate the start and end of the overlap 445 | overlapStart := segment.StartPosition 446 | if overlapStart < chunkStart { 447 | overlapStart = chunkStart 448 | } 449 | overlapEnd := segmentEnd 450 | if overlapEnd > chunkEnd { 451 | overlapEnd = chunkEnd 452 | } 453 | 454 | // add samples in the chunk and track active sources (players talking at the same time) 455 | for i := overlapStart; i < overlapEnd; i++ { 456 | sampleIndex := i - chunkStart 457 | sampleStartPosition := i - segment.StartPosition 458 | 459 | if sampleStartPosition >= 0 && sampleStartPosition < len(segment.Samples) { 460 | sample := segment.Samples[sampleStartPosition] 461 | if sample != 0 { // ignore silence 462 | samples[sampleIndex] += sample 463 | activeSources[sampleIndex]++ 464 | } 465 | } 466 | } 467 | } 468 | 469 | // normalize and mix samples in the chunk 470 | for sampleIndex := range samples { 471 | // ignore silence 472 | if samples[sampleIndex] == 0 || activeSources[sampleIndex] == 0 { 473 | continue 474 | } 475 | 476 | // normalize the sample if several players are talking at the same time 477 | if activeSources[sampleIndex] > 1 { 478 | mixCoeff := 1.0 / float32(math.Sqrt(float64(activeSources[sampleIndex]))) 479 | samples[sampleIndex] *= mixCoeff 480 | } 481 | } 482 | 483 | // find the maximum value in the chunk to potentially normalize 484 | maxSampleValue := float32(1.0) 485 | for _, v := range samples { 486 | f := math.Abs(float64(v)) 487 | if f > float64(maxSampleValue) { 488 | maxSampleValue = float32(f) 489 | } 490 | } 491 | 492 | // normalize if needed 493 | if maxSampleValue > 1.0 { 494 | for i := range samples { 495 | samples[i] /= maxSampleValue 496 | } 497 | } 498 | 499 | pcmBuffer := samplesToInt32(samples) 500 | err = writeAudioToWav(enc, sampleRate, pcmBuffer) 501 | if err != nil { 502 | return 503 | } 504 | } 505 | 506 | wavFilePath = filepath.Join(outputPath, demoName+".wav") 507 | } 508 | 509 | func Extract(options common.ExtractOptions) { 510 | common.AssertLibraryFilesExist() 511 | 512 | demoPath := options.DemoPath 513 | parserConfig := dem.DefaultParserConfig 514 | parser := dem.NewParserWithConfig(options.File, parserConfig) 515 | defer parser.Close() 516 | var segmentsPerPlayer = map[string][]common.VoiceSegment{} 517 | var format msgs2.VoiceDataFormatT 518 | 519 | parser.RegisterNetMessageHandler(func(m *msgs2.CSVCMsg_VoiceData) { 520 | steamID := m.GetXuid() 521 | if len(options.SteamIDs) > 0 && !slices.Contains(options.SteamIDs, fmt.Sprintf("%d", steamID)) { 522 | return 523 | } 524 | 525 | playerID := common.GetPlayerID(parser, steamID) 526 | // Opus format since the arms race update (07/02/2024), Steam Voice format before that. 527 | format = m.GetAudio().GetFormat() 528 | 529 | if format != msgs2.VoiceDataFormatT_VOICEDATA_FORMAT_STEAM && format != msgs2.VoiceDataFormatT_VOICEDATA_FORMAT_OPUS { 530 | common.UnsupportedCodecError = &common.UnsupportedCodec{ 531 | Name: format.String(), 532 | } 533 | parser.Cancel() 534 | return 535 | } 536 | 537 | if playerID == "" { 538 | return 539 | } 540 | 541 | if segmentsPerPlayer[playerID] == nil { 542 | segmentsPerPlayer[playerID] = make([]common.VoiceSegment, 0) 543 | } 544 | 545 | segmentsPerPlayer[playerID] = append(segmentsPerPlayer[playerID], common.VoiceSegment{ 546 | Data: m.Audio.VoiceData, 547 | Timestamp: parser.CurrentTime().Seconds(), 548 | }) 549 | }) 550 | 551 | err := parser.ParseToEnd() 552 | 553 | isCorruptedDemo := errors.Is(err, dem.ErrUnexpectedEndOfDemo) 554 | isCanceled := errors.Is(err, dem.ErrCancelled) 555 | if err != nil && !isCorruptedDemo && !isCanceled { 556 | common.HandleError(common.Error{ 557 | Message: fmt.Sprintf("Failed to parse demo: %s\n", demoPath), 558 | Err: err, 559 | ExitCode: common.ParsingError, 560 | }) 561 | return 562 | } 563 | 564 | if isCanceled { 565 | return 566 | } 567 | 568 | if len(segmentsPerPlayer) == 0 { 569 | common.HandleError(common.Error{ 570 | Message: fmt.Sprintf("No voice data found in demo %s\n", demoPath), 571 | ExitCode: common.NoVoiceDataFound, 572 | }) 573 | return 574 | } 575 | 576 | fmt.Println("Parsing done, generating audio files...") 577 | durationSeconds := parser.CurrentTime().Seconds() 578 | demoName := strings.TrimSuffix(filepath.Base(demoPath), filepath.Ext(demoPath)) 579 | if options.Mode == common.ModeSingleFull { 580 | generateAudioFileWithMergedVoices(segmentsPerPlayer, format, durationSeconds, demoName, options.OutputPath) 581 | } else if options.Mode == common.ModeSplitFull { 582 | generateAudioFilesWithDemoLength(segmentsPerPlayer, format, durationSeconds, demoName, options.OutputPath) 583 | } else { 584 | generateAudioFilesWithCompactLength(segmentsPerPlayer, format, demoName, options) 585 | } 586 | } 587 | --------------------------------------------------------------------------------