Voice
Voice proximity. Server menentukan siapa mendengar siapa; audio di luar RakNet.
- Jenis
- Go + sidecar
- Status
- bawaan, sudah dikompilasi
- Native
VoiceJoinVoiceLeaveVoiceMuteVoiceDeafenVoicePeersVoiceInRangeVoiceSetRange- Hook
player::spawnplayer::updateplayer::disconnect
Konfigurasi
| Variable | Default |
|---|---|
KNIGHT_VOICE_URL | http://127.0.0.1:7788 |
KNIGHT_VOICE_RANGE | 30 |
Mengaktifkan
Tambahkan path ke server.plugins (atau `plugins` di server.cfg).
knight
server.plugins = ["plugins/voice"];Alur kerja
sequenceDiagram
participant C as Game client
participant S as Knight server
participant P as plugins/voice
participant V as knight-voice sidecar
participant A as Audio transport
C->>S: connect / spawn
S->>P: player::spawn
P->>V: POST /join
V-->>P: token + peers
loop every sync update
S->>P: player::update
P->>V: POST /pos
V-->>P: peers in range
end
C->>A: audio stream (WebRTC, not RakNet)
A-->>C: audio of nearby players
S->>P: player::disconnect
P->>V: POST /leaveKode sumber
plugins/voice/voice.go
315 baris
go
// Package voice is a Knight plugin that adds proximity voice to a gamemode.
//
// Audio never travels over SA-MP: this plugin reports player positions to a
// voice sidecar (see internal/voice) and exposes whether a player is in voice
// range. The client and the audio transport are separate.
//
// Configuration (environment):
//
// KNIGHT_VOICE_URL sidecar base URL, default http://127.0.0.1:7788
// KNIGHT_VOICE_RANGE hearing radius in world units, default 30
//
// Natives exposed to gamemodes:
//
// VoiceJoin(playerid) -> bool register with the sidecar
// VoiceLeave(playerid) -> bool remove from the sidecar
// VoiceMute(playerid, bool) -> bool
// VoiceDeafen(playerid, bool) -> bool
// VoicePeers(playerid) -> string comma-separated audible ids
// VoiceInRange(playerid, other) -> bool
// VoiceSetRange(float) -> bool
package voice
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"knight/pkg/plugin"
)
type memberState struct {
x, y, z float64
world int
peers []int
}
type voicePlugin struct {
api plugin.API
url string
rng float64
http *http.Client
mu sync.Mutex
state map[int]*memberState
}
func init() { plugin.Register(func() plugin.Plugin { return &voicePlugin{} }) }
func (v *voicePlugin) Info() plugin.Info {
return plugin.Info{Name: "voice", Version: "1.0.0", Author: "Knight"}
}
func (v *voicePlugin) Init(api plugin.API) error {
v.api = api
v.url = strings.TrimRight(os.Getenv("KNIGHT_VOICE_URL"), "/")
if v.url == "" {
v.url = "http://127.0.0.1:7788"
}
v.rng = 30
if s := os.Getenv("KNIGHT_VOICE_RANGE"); s != "" {
if f, err := strconv.ParseFloat(s, 64); err == nil && f > 0 {
v.rng = f
}
}
v.http = &http.Client{Timeout: 2 * time.Second}
v.state = map[int]*memberState{}
api.RegisterNative("VoiceJoin", v.join)
api.RegisterNative("VoiceLeave", v.leave)
api.RegisterNative("VoiceMute", v.mute)
api.RegisterNative("VoiceDeafen", v.deafen)
api.RegisterNative("VoicePeers", v.peers)
api.RegisterNative("VoiceInRange", v.inRange)
api.RegisterNative("VoiceSetRange", v.setRange)
api.Hook("player::spawn", plugin.PriorityNormal, v.onSpawn)
api.Hook("player::update", plugin.PriorityLate, v.onUpdate)
api.Hook("player::disconnect", plugin.PriorityNormal, v.onDisconnect)
return nil
}
func (v *voicePlugin) Shutdown() {
v.mu.Lock()
ids := make([]int, 0, len(v.state))
for id := range v.state {
ids = append(ids, id)
}
v.state = map[int]*memberState{}
v.mu.Unlock()
for _, id := range ids {
v.post("/leave", map[string]any{"id": id})
}
}
func (v *voicePlugin) onSpawn(ctx plugin.Ctx) bool {
v.callJoin(int(ctx.Int(0)))
return true
}
func (v *voicePlugin) onDisconnect(ctx plugin.Ctx) bool {
v.callLeave(int(ctx.Int(0)))
return true
}
// onUpdate forwards position at most ~5 times per second per player.
func (v *voicePlugin) onUpdate(ctx plugin.Ctx) bool {
id := int(ctx.Int(0))
x, y, z := ctx.Float(1), ctx.Float(2), ctx.Float(3)
v.mu.Lock()
m, ok := v.state[id]
if !ok {
v.mu.Unlock()
return true
}
m.x, m.y, m.z = x, y, z
v.mu.Unlock()
resp, err := v.post("/pos", map[string]any{"id": id, "x": x, "y": y, "z": z, "world": 0})
if err == nil {
v.storePeers(id, resp)
}
return true
}
func (v *voicePlugin) callJoin(id int) bool {
v.mu.Lock()
v.state[id] = &memberState{}
v.mu.Unlock()
resp, err := v.post("/join", map[string]any{"id": id, "name": ""})
if err != nil {
v.api.Log("voice: join %d: %v", id, err)
return false
}
v.storePeers(id, resp)
return true
}
func (v *voicePlugin) callLeave(id int) bool {
v.mu.Lock()
delete(v.state, id)
v.mu.Unlock()
_, err := v.post("/leave", map[string]any{"id": id})
if err != nil {
v.api.Log("voice: leave %d: %v", id, err)
return false
}
return true
}
func (v *voicePlugin) post(path string, body any) (map[string]any, error) {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
resp, err := v.http.Post(v.url+path, "application/json", bytes.NewReader(b))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out, nil
}
// refreshAll re-reads each member's peer list from the sidecar, resending the
// last known position so membership stays correct.
func (v *voicePlugin) refreshAll() {
type snap struct {
id int
x, y, z float64
world int
}
v.mu.Lock()
snaps := make([]snap, 0, len(v.state))
for id, m := range v.state {
snaps = append(snaps, snap{id, m.x, m.y, m.z, m.world})
}
v.mu.Unlock()
for _, s := range snaps {
resp, err := v.post("/pos", map[string]any{"id": s.id, "x": s.x, "y": s.y, "z": s.z, "world": s.world})
if err == nil {
v.storePeers(s.id, resp)
}
}
}
func (v *voicePlugin) storePeers(id int, resp map[string]any) {
arr, _ := resp["peers"].([]any)
peers := make([]int, 0, len(arr))
for _, e := range arr {
if f, ok := e.(float64); ok {
peers = append(peers, int(f))
}
}
v.mu.Lock()
if m, ok := v.state[id]; ok {
m.peers = peers
}
v.mu.Unlock()
}
func (v *voicePlugin) join(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
id := int(argInt(args, 0))
return plugin.BoolV(v.callJoin(id))
}
func (v *voicePlugin) leave(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
return plugin.BoolV(v.callLeave(int(argInt(args, 0))))
}
func (v *voicePlugin) mute(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
id := int(argInt(args, 0))
resp, err := v.post("/mute", map[string]any{"id": id, "muted": argBool(args, 1)})
if err != nil {
return plugin.BoolV(false)
}
// A mute changes what other listeners hear, so refresh their cached lists.
v.refreshAll()
v.storePeers(id, resp)
return plugin.BoolV(true)
}
func (v *voicePlugin) deafen(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
id := int(argInt(args, 0))
resp, err := v.post("/deafen", map[string]any{"id": id, "deafened": argBool(args, 1)})
if err != nil {
return plugin.BoolV(false)
}
v.storePeers(id, resp)
return plugin.BoolV(true)
}
func (v *voicePlugin) peers(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
id := int(argInt(args, 0))
v.mu.Lock()
defer v.mu.Unlock()
m, ok := v.state[id]
if !ok {
return plugin.StrV("")
}
parts := make([]string, len(m.peers))
for i, p := range m.peers {
parts[i] = strconv.Itoa(p)
}
return plugin.StrV(strings.Join(parts, ","))
}
// inRange answers locally from the last known positions, without the sidecar.
func (v *voicePlugin) inRange(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
a := int(argInt(args, 0))
b := int(argInt(args, 1))
v.mu.Lock()
defer v.mu.Unlock()
ma, ok := v.state[a]
if !ok {
return plugin.BoolV(false)
}
mb, ok := v.state[b]
if !ok {
return plugin.BoolV(false)
}
dx, dy, dz := ma.x-mb.x, ma.y-mb.y, ma.z-mb.z
return plugin.BoolV(dx*dx+dy*dy+dz*dz <= v.rng*v.rng)
}
func (v *voicePlugin) setRange(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
r := argFloat(args, 0)
if r <= 0 {
return plugin.BoolV(false)
}
v.mu.Lock()
v.rng = r
v.mu.Unlock()
return plugin.BoolV(true)
}
func argInt(a []plugin.Value, i int) int64 {
if i < len(a) {
return a[i].Int
}
return 0
}
func argFloat(a []plugin.Value, i int) float64 {
if i < len(a) {
if a[i].Kind == plugin.KindFloat {
return a[i].Flt
}
return float64(a[i].Int)
}
return 0
}
func argBool(a []plugin.Value, i int) bool {
if i < len(a) {
return a[i].Bool
}
return false
}
// FormatPeers is a small helper used by documentation examples.
func FormatPeers(peers []int) string {
parts := make([]string, len(peers))
for i, p := range peers {
parts[i] = fmt.Sprint(p)
}
return strings.Join(parts, ",")
}
internal/voice/voice.go
274 baris
go
// Package voice implements proximity-based voice rooms for Knight.
//
// The SA-MP protocol has no voice channel, so audio never travels over RakNet.
// The server is only the authority on *who should hear whom*: the game plugin
// reports player positions and receives a membership update; the audio itself
// is carried by a WebRTC sidecar (or any other transport the client speaks).
//
// This package contains the transport-independent core (rooms, proximity
// membership, mute/deafen) so it can be tested without a network, plus a small
// HTTP sidecar in sidecar.go that exposes it to clients.
package voice
import (
"math"
"sort"
"sync"
)
// Settings tunes proximity grouping.
type Settings struct {
// Range is the hearing radius in world units. 0 means DefaultRange.
Range float64
// Teams, when true, only connects players whose Team values match.
Teams bool
}
// DefaultRange is the hearing radius used when Settings.Range is zero.
const DefaultRange = 30.0
// Member is one connected player.
type Member struct {
ID int
Name string
Team int
X float64
Y float64
Z float64
World int
// Muted silences this member's outgoing audio.
Muted bool
// Deafened disables this member's incoming audio (still audible to others).
Deafened bool
}
// Room is a set of voice members with proximity-based audio routing.
type Room struct {
mu sync.RWMutex
settings Settings
members map[int]*Member
// order preserves insertion order so membership output is deterministic.
order []int
// onUpdate is called (outside the lock) whenever membership changes.
onUpdate func(id int, peers []int)
}
// NewRoom creates an empty room.
func NewRoom(settings Settings) *Room {
if settings.Range <= 0 {
settings.Range = DefaultRange
}
return &Room{settings: settings, members: map[int]*Member{}}
}
// SetUpdateHook installs a callback invoked with the audible peer list for a
// member after every change (join, leave, move, mute, settings).
func (r *Room) SetUpdateHook(fn func(id int, peers []int)) {
r.mu.Lock()
r.onUpdate = fn
r.mu.Unlock()
}
// Settings returns the current settings.
func (r *Room) Settings() Settings {
r.mu.RLock()
defer r.mu.RUnlock()
return r.settings
}
// SetSettings replaces the settings and recomputes membership.
func (r *Room) SetSettings(s Settings) {
if s.Range <= 0 {
s.Range = DefaultRange
}
r.mu.Lock()
r.settings = s
r.mu.Unlock()
r.notifyAll()
}
// Join adds or updates a member.
func (r *Room) Join(m Member) {
r.mu.Lock()
if _, exists := r.members[m.ID]; !exists {
r.order = append(r.order, m.ID)
}
cp := m
r.members[m.ID] = &cp
peers := r.peersLocked(m.ID)
ids := append([]int(nil), r.order...)
r.mu.Unlock()
r.deliver(m.ID, peers)
// Existing members gain a potential speaker, so refresh them as well.
for _, other := range ids {
if other != m.ID {
r.notify(other)
}
}
}
// Leave removes a member and tells the remaining members.
func (r *Room) Leave(id int) {
r.mu.Lock()
if _, ok := r.members[id]; !ok {
r.mu.Unlock()
return
}
delete(r.members, id)
for i, v := range r.order {
if v == id {
r.order = append(r.order[:i], r.order[i+1:]...)
break
}
}
ids := append([]int(nil), r.order...)
r.mu.Unlock()
for _, other := range ids {
r.notify(other)
}
}
// UpdatePosition moves a member and pushes new membership to everyone affected.
func (r *Room) UpdatePosition(id int, x, y, z float64, world int) {
r.mu.Lock()
m, ok := r.members[id]
if !ok {
r.mu.Unlock()
return
}
m.X, m.Y, m.Z, m.World = x, y, z, world
ids := append([]int(nil), r.order...)
r.mu.Unlock()
// A move can change who hears whom for any member in range, so notify all.
for _, other := range ids {
r.notify(other)
}
}
// SetMuted silences or unsilences a member.
func (r *Room) SetMuted(id int, muted bool) bool {
r.mu.Lock()
m, ok := r.members[id]
if !ok {
r.mu.Unlock()
return false
}
m.Muted = muted
ids := append([]int(nil), r.order...)
r.mu.Unlock()
for _, other := range ids {
r.notify(other)
}
return true
}
// SetDeafened enables or disables a member's incoming audio.
func (r *Room) SetDeafened(id int, deafened bool) bool {
r.mu.Lock()
m, ok := r.members[id]
if !ok {
r.mu.Unlock()
return false
}
m.Deafened = deafened
r.mu.Unlock()
r.notify(id)
return true
}
// Member returns a copy of a member.
func (r *Room) Member(id int) (Member, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.members[id]
if !ok {
return Member{}, false
}
return *m, true
}
// Peers returns the ids this member should hear, sorted.
func (r *Room) Peers(id int) []int {
r.mu.RLock()
defer r.mu.RUnlock()
return r.peersLocked(id)
}
// Members returns every member id, in join order.
func (r *Room) Members() []int {
r.mu.RLock()
defer r.mu.RUnlock()
return append([]int(nil), r.order...)
}
// Len returns the member count.
func (r *Room) Len() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.members)
}
// peersLocked computes the audible peers of id. A peer is heard when it is in
// the same world, inside the range, not muted, and allowed by the team setting.
// The listener's deafen flag removes everyone.
func (r *Room) peersLocked(id int) []int {
m, ok := r.members[id]
if !ok || m.Deafened {
return nil
}
var out []int
for other, om := range r.members {
if other == id {
continue
}
if om.Muted || om.World != m.World {
continue
}
if r.settings.Teams && om.Team != m.Team {
continue
}
if distance(m, om) > r.settings.Range {
continue
}
out = append(out, other)
}
sort.Ints(out)
return out
}
func distance(a, b *Member) float64 {
dx, dy, dz := a.X-b.X, a.Y-b.Y, a.Z-b.Z
return math.Sqrt(dx*dx + dy*dy + dz*dz)
}
func (r *Room) notify(id int) {
r.mu.RLock()
fn := r.onUpdate
var peers []int
if _, ok := r.members[id]; ok {
peers = r.peersLocked(id)
}
r.mu.RUnlock()
if fn != nil {
fn(id, peers)
}
}
func (r *Room) notifyAll() {
r.mu.RLock()
ids := append([]int(nil), r.order...)
r.mu.RUnlock()
for _, id := range ids {
r.notify(id)
}
}
func (r *Room) deliver(id int, peers []int) {
r.mu.RLock()
fn := r.onUpdate
r.mu.RUnlock()
if fn != nil {
fn(id, peers)
}
}
internal/voice/sidecar.go
173 baris
go
package voice
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
)
// Sidecar is the HTTP service the game server and voice clients talk to.
//
// Endpoints (all JSON):
//
// POST /join {id,name} -> {token, peers}
// POST /leave {id}
// POST /pos {id,x,y,z,world} -> {peers}
// POST /mute {id,muted} -> {ok}
// POST /deafen {id,deafened} -> {ok}
// GET /peers?id=N -> {peers}
// GET /health -> {ok,members}
//
// The game server uses /join, /leave and /pos; the audio client uses /peers to
// learn which streams to subscribe to. No audio passes through this service.
type Sidecar struct {
room *Room
mu sync.Mutex
// tokens authenticate a player id (kept simple and testable).
tokens map[int]string
next int
}
// NewSidecar wraps a room in an HTTP handler.
func NewSidecar(settings Settings) *Sidecar {
return &Sidecar{room: NewRoom(settings), tokens: map[int]string{}}
}
// Room exposes the underlying room (tests and the game plugin).
func (s *Sidecar) Room() *Room { return s.room }
type joinReq struct {
ID int `json:"id"`
Name string `json:"name"`
Team int `json:"team"`
}
type peersResp struct {
Token string `json:"token,omitempty"`
Peers []int `json:"peers"`
Error string `json:"error,omitempty"`
}
// newPeersResp guarantees "peers" is a JSON array (never null), so clients can
// iterate it without a nil check.
func newPeersResp(token string, peers []int) peersResp {
if peers == nil {
peers = []int{}
}
return peersResp{Token: token, Peers: peers}
}
type posReq struct {
ID int `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
World int `json:"world"`
}
type boolReq struct {
ID int `json:"id"`
Muted bool `json:"muted"`
Deafened bool `json:"deafened"`
}
func (s *Sidecar) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/join":
var req joinReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
token := s.issueToken(req.ID)
s.room.Join(Member{ID: req.ID, Name: req.Name, Team: req.Team})
writeJSON(w, newPeersResp(token, s.room.Peers(req.ID)))
case r.Method == http.MethodPost && r.URL.Path == "/leave":
var req joinReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
s.room.Leave(req.ID)
s.revoke(req.ID)
writeJSON(w, newPeersResp("", nil))
case r.Method == http.MethodPost && r.URL.Path == "/pos":
var req posReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
s.room.UpdatePosition(req.ID, req.X, req.Y, req.Z, req.World)
writeJSON(w, newPeersResp("", s.room.Peers(req.ID)))
case r.Method == http.MethodPost && r.URL.Path == "/mute":
var req boolReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
s.room.SetMuted(req.ID, req.Muted)
writeJSON(w, newPeersResp("", s.room.Peers(req.ID)))
case r.Method == http.MethodPost && r.URL.Path == "/deafen":
var req boolReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
_ = s.room.SetDeafened(req.ID, req.Deafened)
writeJSON(w, newPeersResp("", s.room.Peers(req.ID)))
case r.Method == http.MethodGet && r.URL.Path == "/peers":
id, _ := strconv.Atoi(r.URL.Query().Get("id"))
writeJSON(w, newPeersResp("", s.room.Peers(id)))
case r.Method == http.MethodGet && r.URL.Path == "/health":
writeJSON(w, map[string]any{"ok": true, "members": s.room.Len()})
default:
writeErr(w, http.StatusNotFound, fmt.Errorf("unknown endpoint %s %s", r.Method, r.URL.Path))
}
}
func (s *Sidecar) issueToken(id int) string {
s.mu.Lock()
defer s.mu.Unlock()
s.next++
tok := fmt.Sprintf("v%d-%d", id, s.next)
s.tokens[id] = tok
return tok
}
func (s *Sidecar) revoke(id int) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.tokens, id)
}
// ValidateToken reports whether token belongs to id.
func (s *Sidecar) ValidateToken(id int, token string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.tokens[id] != "" && s.tokens[id] == token
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}
// ListenAndServe starts the sidecar on addr and blocks.
func ListenAndServe(addr string, settings Settings) error {
srv := &http.Server{
Addr: addr,
Handler: NewSidecar(settings),
ReadHeaderTimeout: 5 * time.Second,
}
return srv.ListenAndServe()
}
cmd/knight-voice/main.go
30 baris
go
// Command knight-voice runs the voice sidecar: it decides which players hear
// each other and exposes that over HTTP. Audio itself is carried by the client
// transport (WebRTC or another), never by the SA-MP server.
package main
import (
"flag"
"log"
"net/http"
"time"
"knight/internal/voice"
)
func main() {
addr := flag.String("addr", ":7788", "HTTP listen address")
rng := flag.Float64("range", voice.DefaultRange, "hearing radius in world units")
teams := flag.Bool("teams", false, "only connect players on the same team")
flag.Parse()
handler := voice.NewSidecar(voice.Settings{Range: *rng, Teams: *teams})
srv := &http.Server{
Addr: *addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("voice sidecar listening on %s (range %.1f, teams=%v)", *addr, *rng, *teams)
log.Fatal(srv.ListenAndServe())
}