diff --git a/port/android/app/src/main/AndroidManifest.xml b/port/android/app/src/main/AndroidManifest.xml
index 2af054a9..ff59bec9 100644
--- a/port/android/app/src/main/AndroidManifest.xml
+++ b/port/android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,9 @@
+
+
+
diff --git a/port/android/app/src/main/java/com/halo/decomp/LauncherActivity.java b/port/android/app/src/main/java/com/halo/decomp/LauncherActivity.java
index 1e2d79cf..0de1dadd 100644
--- a/port/android/app/src/main/java/com/halo/decomp/LauncherActivity.java
+++ b/port/android/app/src/main/java/com/halo/decomp/LauncherActivity.java
@@ -1,7 +1,10 @@
package com.halo.decomp;
+import android.Manifest;
import android.app.Activity;
import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
import android.graphics.Color;
import android.net.Uri;
import android.os.Bundle;
@@ -35,6 +38,7 @@ import java.nio.channels.FileChannel;
*/
public class LauncherActivity extends Activity {
private static final int PICK_IMAGE = 1;
+ private static final int ASK_MICROPHONE = 2;
private File dataRoot;
private TextView status;
@@ -53,12 +57,37 @@ public class LauncherActivity extends Activity {
passOnHardwareId();
passOnInvite(getIntent());
if (haveData()) {
- startGame();
+ if (!askForMicrophone())
+ startGame();
return;
}
buildInterface();
}
+ /**
+ * The voice chat's microphone (port/android/host/host_voice.c), asked
+ * for here, once, on this flat panel, before the game starts: in the
+ * game, the question would stop it. Returns true if it asked (the game
+ * starts on the answer).
+ */
+ private boolean askForMicrophone() {
+ SharedPreferences preferences = getSharedPreferences("launcher", MODE_PRIVATE);
+
+ if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
+ || preferences.getBoolean("asked_microphone", false))
+ return false;
+ preferences.edit().putBoolean("asked_microphone", true).apply();
+ requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, ASK_MICROPHONE);
+ return true;
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] results) {
+ super.onRequestPermissionsResult(requestCode, permissions, results);
+ if (requestCode == ASK_MICROPHONE)
+ startGame();
+ }
+
/**
* An internet play invite link the app was opened with: the game
* (port/linux/src/p2p.c) picks it up from join_link.txt, whether it is
@@ -111,6 +140,23 @@ public class LauncherActivity extends Activity {
partial.delete();
}
+ /**
+ * Whether the microphone may be used, for the game (port/linux/src/
+ * voice.c), which opens it only then: opening it otherwise would have
+ * Android ask in the middle of the game.
+ */
+ private void passOnMicrophonePermission() {
+ if (dataRoot == null)
+ return;
+ boolean granted =
+ checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED;
+ try (OutputStream out = new FileOutputStream(new File(dataRoot, "microphone_permission.txt"))) {
+ out.write((granted ? "1" : "0").getBytes("UTF-8"));
+ } catch (java.io.IOException e) {
+ // the game then does without the microphone
+ }
+ }
+
/** The game data, or the marker file of the VR self-test (host_xr.c). */
private boolean haveData() {
return dataRoot != null && (new File(dataRoot, "maps/ui.map").isFile() ||
@@ -118,6 +164,7 @@ public class LauncherActivity extends Activity {
}
private void startGame() {
+ passOnMicrophonePermission();
startActivity(new Intent(this, HaloActivity.class));
finish();
}
diff --git a/port/android/guest/runtime/guest_host.h b/port/android/guest/runtime/guest_host.h
index ea15457a..98461060 100644
--- a/port/android/guest/runtime/guest_host.h
+++ b/port/android/guest/runtime/guest_host.h
@@ -131,6 +131,21 @@ int host_xr_copy_layer_array(int layer, unsigned int texture, int array_layer);
/* darkens the eye images from their edges, strength 0..1 (0: off) */
void host_xr_set_vignette(float strength);
+/* ---------- the voice chat (host_voice.c) */
+
+/* opens the voices' playback, and the microphone if asked (else closes
+it): their volume, and the level (0..1 of full scale) the microphone's
+sound must reach to be sent */
+int host_voice_start(int microphone, float volume, float threshold);
+void host_voice_stop(void);
+/* the microphone's speech since asked: Opus packets, each after a byte of
+its length; returns the bytes */
+int host_voice_take_packets(void *buffer, int size);
+/* the microphone's level (0..1000) */
+int host_voice_level(void);
+/* a speaker's packet (sequence numbered), played at these gains */
+void host_voice_play(int id, int sequence, const void *data, int length, float gain_left, float gain_right);
+
/* ---------- Android */
/* the storage directories the port uses, copied into buffer */
diff --git a/port/android/host/host_voice.c b/port/android/host/host_voice.c
new file mode 100644
index 00000000..39f31ab1
--- /dev/null
+++ b/port/android/host/host_voice.c
@@ -0,0 +1,494 @@
+/*
+HOST_VOICE.C
+
+The voice chat's sound (the VR port's; port/linux/game/network_voice.c
+sends and receives it): the microphone, Opus, and the voices played. It
+runs here, natively, where SDL's audio threads may call it.
+
+- The microphone: SDL's default recording device, mono at 16 kHz, with
+ Android's voice communication preset (its echo cancelling and noise
+ suppression: SDL_HINT_ANDROID_AAUDIO_INPUT_PRESET). Every 20 ms of it
+ that is loud enough to be speech (or follows speech closely, VAD_HANGOVER)
+ is encoded (Opus, VOIP, 16 kbit/s) for the guest to send.
+- The voices: each speaker's packets, decoded as they come (a gap in their
+ numbers concealed by Opus's packet loss concealment), wait in the
+ speaker's buffer until JITTER_START of them is there, and are mixed, each
+ at the gains its latest packet was given (left and right: where the
+ speaker is), into a stream of their own.
+
+The devices are opened and closed on a thread of their own (voice_worker):
+opening a microphone can take a long while (an emulator's took a minute),
+which the game, whose clock the other machines follow, must not wait for.
+*/
+
+#include "host.h"
+
+#include
+#include
+#include
+#include
+
+enum
+{
+ VOICE_RATE = 16000,
+ FRAME_SAMPLES = VOICE_RATE / 50,
+ MAXIMUM_PACKET = 200,
+ MAXIMUM_SPEAKERS = 32,
+ /* a speaker's buffer: two seconds */
+ SPEAKER_BUFFER = VOICE_RATE * 2,
+ /* played from once this much is there, after silence */
+ JITTER_START = FRAME_SAMPLES * 3,
+ /* more than this behind: the oldest dropped */
+ JITTER_MAXIMUM = FRAME_SAMPLES * 15,
+ /* frames sent on after the voice drops below the threshold */
+ VAD_HANGOVER = 15,
+ BITRATE = 16000,
+};
+
+struct speaker
+{
+ int used;
+ int id;
+ OpusDecoder *decoder;
+ int has_sequence;
+ unsigned short sequence;
+ short buffer[SPEAKER_BUFFER];
+ int read;
+ int count;
+ int playing;
+ float gain_left;
+ float gain_right;
+ unsigned long long heard_ticks;
+};
+
+static struct
+{
+ SDL_Mutex *lock;
+ /* the devices wanted, which the worker opens and closes to match
+ (under worker_lock); the recording stream is read under it too */
+ pthread_mutex_t worker_lock;
+ pthread_cond_t worker_wake;
+ int worker_started;
+ int want_playback;
+ int want_recording;
+ /* the microphone */
+ SDL_AudioStream *recording;
+ OpusEncoder *encoder;
+ short frame[FRAME_SAMPLES];
+ int frame_count;
+ int hangover;
+ float threshold;
+ int level;
+ /* the voices */
+ SDL_AudioStream *playback;
+ struct speaker speakers[MAXIMUM_SPEAKERS];
+ float volume;
+} voice;
+
+static void voice_lock(void)
+{
+ if (!voice.lock)
+ voice.lock = SDL_CreateMutex();
+ SDL_LockMutex(voice.lock);
+}
+
+static void voice_unlock(void)
+{
+ SDL_UnlockMutex(voice.lock);
+}
+
+/* the voices mixed, as SDL asks for them (on its audio thread) */
+static void SDLCALL voice_playback_callback(void *userdata, SDL_AudioStream *stream, int additional, int total)
+{
+ short mixed[2 * 512];
+ (void)userdata;
+ (void)total;
+
+ while (additional > 0)
+ {
+ int frames = additional / 4;
+ int frame;
+ int index;
+
+ if (frames > 512)
+ frames = 512;
+ if (frames <= 0)
+ frames = 1;
+ memset(mixed, 0, sizeof(mixed));
+ voice_lock();
+ for (index = 0; index < MAXIMUM_SPEAKERS; index++)
+ {
+ struct speaker *speaker = &voice.speakers[index];
+
+ if (!speaker->used)
+ continue;
+ if (!speaker->playing && speaker->count >= JITTER_START)
+ speaker->playing = 1;
+ if (!speaker->playing)
+ continue;
+ for (frame = 0; frame < frames && speaker->count > 0; frame++)
+ {
+ float sample = (float)speaker->buffer[speaker->read] * voice.volume;
+ int left = mixed[2 * frame] + (int)(sample * speaker->gain_left);
+ int right = mixed[2 * frame + 1] + (int)(sample * speaker->gain_right);
+
+ mixed[2 * frame] = (short)(left > 32767 ? 32767 : left < -32768 ? -32768 : left);
+ mixed[2 * frame + 1] = (short)(right > 32767 ? 32767 : right < -32768 ? -32768 : right);
+ speaker->read = (speaker->read + 1) % SPEAKER_BUFFER;
+ speaker->count--;
+ }
+ /* (run dry: wait for enough again) */
+ if (!speaker->count)
+ speaker->playing = 0;
+ }
+ voice_unlock();
+ SDL_PutAudioStreamData(stream, mixed, frames * 4);
+ additional -= frames * 4;
+ }
+}
+
+static int voice_open_playback(void)
+{
+ SDL_AudioSpec spec;
+
+ if (voice.playback)
+ return 1;
+ spec.format = SDL_AUDIO_S16;
+ spec.channels = 2;
+ spec.freq = VOICE_RATE;
+ voice.playback = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, voice_playback_callback,
+ NULL);
+ if (!voice.playback)
+ {
+ host_logf(HOST_LOG_WARN, "voice: cannot open the playback stream: %s", SDL_GetError());
+ return 0;
+ }
+ SDL_ResumeAudioStreamDevice(voice.playback);
+ return 1;
+}
+
+static SDL_AudioStream *voice_open_recording(void)
+{
+ SDL_AudioSpec spec;
+ SDL_AudioStream *recording;
+ int error;
+
+ if (!voice.encoder)
+ {
+ voice.encoder = opus_encoder_create(VOICE_RATE, 1, OPUS_APPLICATION_VOIP, &error);
+ if (!voice.encoder)
+ {
+ host_logf(HOST_LOG_WARN, "voice: cannot make the encoder (%d)", error);
+ return NULL;
+ }
+ opus_encoder_ctl(voice.encoder, OPUS_SET_BITRATE(BITRATE));
+ opus_encoder_ctl(voice.encoder, OPUS_SET_SIGNAL(OPUS_SIGNAL_VOICE));
+ opus_encoder_ctl(voice.encoder, OPUS_SET_INBAND_FEC(1));
+ opus_encoder_ctl(voice.encoder, OPUS_SET_PACKET_LOSS_PERC(10));
+ }
+ /* Android's voice communication preset: echo cancelling, noise
+ suppression (AAUDIO_INPUT_PRESET_VOICE_COMMUNICATION) */
+ SDL_SetHint(SDL_HINT_ANDROID_AAUDIO_INPUT_PRESET, "7");
+ spec.format = SDL_AUDIO_S16;
+ spec.channels = 1;
+ spec.freq = VOICE_RATE;
+ recording = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_RECORDING, &spec, NULL, NULL);
+ if (!recording)
+ {
+ host_logf(HOST_LOG_WARN, "voice: cannot open the microphone: %s", SDL_GetError());
+ return NULL;
+ }
+ SDL_ResumeAudioStreamDevice(recording);
+ host_logf(HOST_LOG_INFO, "voice: the microphone is open");
+ return recording;
+}
+
+static void voice_close_recording(void)
+{
+ SDL_AudioStream *recording;
+
+ pthread_mutex_lock(&voice.worker_lock);
+ recording = voice.recording;
+ voice.recording = NULL;
+ voice.level = 0;
+ pthread_mutex_unlock(&voice.worker_lock);
+ if (!recording)
+ return;
+ SDL_DestroyAudioStream(recording);
+ host_logf(HOST_LOG_INFO, "voice: the microphone is closed");
+}
+
+static void voice_close_playback(void)
+{
+ SDL_AudioStream *playback = voice.playback;
+
+ voice.playback = NULL;
+ if (playback)
+ SDL_DestroyAudioStream(playback);
+}
+
+/* opens and closes the devices as wanted, for as long as the app runs */
+static void *voice_worker(void *unused)
+{
+ (void)unused;
+ pthread_mutex_lock(&voice.worker_lock);
+ for (;;)
+ {
+ int want_playback = voice.want_playback;
+ int want_recording = voice.want_recording;
+ int have_playback = voice.playback != NULL;
+ int have_recording = voice.recording != NULL;
+
+ if (want_playback == have_playback && want_recording == have_recording)
+ {
+ pthread_cond_wait(&voice.worker_wake, &voice.worker_lock);
+ continue;
+ }
+ pthread_mutex_unlock(&voice.worker_lock);
+ if (want_recording && !have_recording)
+ {
+ /* (opened outside the lock, which the game reads it under) */
+ SDL_AudioStream *recording = voice_open_recording();
+
+ pthread_mutex_lock(&voice.worker_lock);
+ voice.recording = recording;
+ pthread_mutex_unlock(&voice.worker_lock);
+ /* (a microphone that will not open is not tried again until
+ wanted anew) */
+ if (!recording)
+ {
+ pthread_mutex_lock(&voice.worker_lock);
+ voice.want_recording = 0;
+ pthread_mutex_unlock(&voice.worker_lock);
+ }
+ }
+ else if (!want_recording && have_recording)
+ {
+ voice_close_recording();
+ }
+ if (want_playback && !have_playback)
+ {
+ if (!voice_open_playback())
+ {
+ pthread_mutex_lock(&voice.worker_lock);
+ voice.want_playback = 0;
+ pthread_mutex_unlock(&voice.worker_lock);
+ }
+ }
+ else if (!want_playback && have_playback)
+ {
+ voice_close_playback();
+ }
+ pthread_mutex_lock(&voice.worker_lock);
+ }
+ return NULL;
+}
+
+static void voice_want(int playback, int recording)
+{
+ if (!voice.worker_started)
+ {
+ voice.worker_started = 1;
+ pthread_mutex_init(&voice.worker_lock, NULL);
+ pthread_cond_init(&voice.worker_wake, NULL);
+ if (host_native_thread_create(voice_worker, NULL, 256 * 1024) != 0)
+ {
+ host_logf(HOST_LOG_WARN, "voice: cannot start the device thread");
+ return;
+ }
+ }
+ pthread_mutex_lock(&voice.worker_lock);
+ voice.want_playback = playback;
+ voice.want_recording = recording;
+ pthread_cond_signal(&voice.worker_wake);
+ pthread_mutex_unlock(&voice.worker_lock);
+}
+
+/* ---------- the guest's side */
+
+int host_voice_start(int microphone, float volume, float threshold)
+{
+ voice_lock();
+ voice.volume = volume < 0.0f ? 0.0f : volume > 4.0f ? 4.0f : volume;
+ voice.threshold = threshold;
+ voice_unlock();
+ voice_want(1, microphone);
+ return 1;
+}
+
+void host_voice_stop(void)
+{
+ int index;
+
+ voice_want(0, 0);
+ voice_lock();
+ for (index = 0; index < MAXIMUM_SPEAKERS; index++)
+ {
+ if (voice.speakers[index].decoder)
+ opus_decoder_destroy(voice.speakers[index].decoder);
+ }
+ memset(voice.speakers, 0, sizeof(voice.speakers));
+ voice_unlock();
+}
+
+int host_voice_take_packets(void *buffer, int size)
+{
+ unsigned char *out = buffer;
+ int used = 0;
+
+ if (!voice.worker_started)
+ return 0;
+ pthread_mutex_lock(&voice.worker_lock);
+ if (!voice.recording)
+ {
+ voice.frame_count = 0;
+ voice.hangover = 0;
+ pthread_mutex_unlock(&voice.worker_lock);
+ return 0;
+ }
+ for (;;)
+ {
+ int wanted = (FRAME_SAMPLES - voice.frame_count) * (int)sizeof(short);
+ int got = SDL_GetAudioStreamData(voice.recording, voice.frame + voice.frame_count, wanted);
+ double energy = 0.0;
+ int index;
+
+ if (got <= 0)
+ break;
+ voice.frame_count += got / (int)sizeof(short);
+ if (voice.frame_count < FRAME_SAMPLES)
+ break;
+ voice.frame_count = 0;
+ for (index = 0; index < FRAME_SAMPLES; index++)
+ energy += (double)voice.frame[index] * voice.frame[index];
+ energy = SDL_sqrt(energy / FRAME_SAMPLES) / 32768.0;
+ voice.level = (int)(energy * 1000.0);
+ if (energy >= voice.threshold)
+ voice.hangover = VAD_HANGOVER;
+ else if (voice.hangover > 0)
+ voice.hangover--;
+ else
+ continue;
+ if (used + 1 + MAXIMUM_PACKET > size)
+ continue;
+ {
+ int length = opus_encode(voice.encoder, voice.frame, FRAME_SAMPLES, out + used + 1, MAXIMUM_PACKET);
+
+ if (length > 0 && length < 256)
+ {
+ out[used] = (unsigned char)length;
+ used += 1 + length;
+ }
+ }
+ }
+ pthread_mutex_unlock(&voice.worker_lock);
+ return used;
+}
+
+int host_voice_level(void)
+{
+ return voice.level;
+}
+
+void host_voice_play(int id, int sequence, const void *data, int length, float gain_left, float gain_right)
+{
+ struct speaker *speaker = NULL;
+ struct speaker *oldest = NULL;
+ short decoded[FRAME_SAMPLES * 6];
+ int frames[4];
+ int frame_count = 0;
+ int index;
+
+ if (length <= 0 || length > MAXIMUM_PACKET)
+ return;
+ voice_lock();
+ for (index = 0; index < MAXIMUM_SPEAKERS && !speaker; index++)
+ {
+ if (voice.speakers[index].used && voice.speakers[index].id == id)
+ speaker = &voice.speakers[index];
+ }
+ if (!speaker)
+ {
+ for (index = 0; index < MAXIMUM_SPEAKERS && !speaker; index++)
+ {
+ if (!voice.speakers[index].used)
+ speaker = &voice.speakers[index];
+ else if (!oldest || voice.speakers[index].heard_ticks < oldest->heard_ticks)
+ oldest = &voice.speakers[index];
+ }
+ if (!speaker)
+ speaker = oldest;
+ if (!speaker->decoder)
+ {
+ int error;
+
+ speaker->decoder = opus_decoder_create(VOICE_RATE, 1, &error);
+ if (!speaker->decoder)
+ {
+ voice_unlock();
+ return;
+ }
+ }
+ else
+ {
+ opus_decoder_ctl(speaker->decoder, OPUS_RESET_STATE);
+ }
+ speaker->used = 1;
+ speaker->id = id;
+ speaker->has_sequence = 0;
+ speaker->read = speaker->count = 0;
+ speaker->playing = 0;
+ }
+ speaker->heard_ticks = SDL_GetTicks();
+ speaker->gain_left = gain_left;
+ speaker->gain_right = gain_right;
+ {
+ unsigned short number = (unsigned short)sequence;
+
+ if (speaker->has_sequence)
+ {
+ short gap = (short)(number - speaker->sequence);
+
+ /* (late, or again: dropped) */
+ if (gap <= 0)
+ {
+ voice_unlock();
+ return;
+ }
+ /* (lost ones: concealed, a few) */
+ if (gap > 1 && gap <= 4 && speaker->count > 0)
+ {
+ int missing;
+
+ for (missing = 1; missing < gap && frame_count < 3; missing++)
+ {
+ frames[frame_count] = opus_decode(speaker->decoder, NULL, 0, decoded + frame_count * FRAME_SAMPLES,
+ FRAME_SAMPLES, 0);
+ frame_count++;
+ }
+ }
+ }
+ speaker->has_sequence = 1;
+ speaker->sequence = number;
+ }
+ frames[frame_count] = opus_decode(speaker->decoder, data, length, decoded + frame_count * FRAME_SAMPLES,
+ FRAME_SAMPLES * 2, 0);
+ frame_count++;
+ for (index = 0; index < frame_count; index++)
+ {
+ int sample;
+
+ for (sample = 0; sample < frames[index] && sample < FRAME_SAMPLES * 2; sample++)
+ {
+ if (speaker->count >= JITTER_MAXIMUM)
+ {
+ speaker->read = (speaker->read + 1) % SPEAKER_BUFFER;
+ speaker->count--;
+ }
+ speaker->buffer[(speaker->read + speaker->count) % SPEAKER_BUFFER] =
+ decoded[index * FRAME_SAMPLES + sample];
+ speaker->count++;
+ }
+ }
+ voice_unlock();
+}
diff --git a/port/android/host_imports.list b/port/android/host_imports.list
index 4a73e57d..d6c4820e 100644
--- a/port/android/host_imports.list
+++ b/port/android/host_imports.list
@@ -58,6 +58,11 @@ host_xr_copy_layer_region
host_xr_end_frame
host_xr_haptic
host_xr_set_vignette
+host_voice_start
+host_voice_stop
+host_voice_take_packets
+host_voice_level
+host_voice_play
host_xr_copy_layer_array
host_android_path
diff --git a/port/linux/game/network_distributed.c b/port/linux/game/network_distributed.c
index bf94522b..f64b5c06 100644
--- a/port/linux/game/network_distributed.c
+++ b/port/linux/game/network_distributed.c
@@ -3051,6 +3051,7 @@ void network_distributed_new_game(
network_objects_new_game();
network_damage_new_game();
network_vr_new_game();
+ network_voice_new_game();
}
/* after each tick (game_time.c) */
@@ -3093,6 +3094,7 @@ void network_distributed_tick(
distributed_statistics_due = FALSE;
distributed_host_send_players();
network_vr_tick();
+ network_voice_tick();
distributed_send_pickups();
if (game_time_get() % GAME_STATE_INTERVAL_TICKS == 0)
distributed_send_game_state(NONE);
@@ -3102,6 +3104,7 @@ void network_distributed_tick(
distributed_note_own_positions();
distributed_client_send_inputs();
network_vr_tick();
+ network_voice_tick();
distributed_client_send_predictions();
network_objects_client_tick();
network_damage_client_tick();
@@ -3577,6 +3580,7 @@ void network_distributed_handle_message(
case _distributed_message_client_ready: entry_size = 0; break;
case _distributed_message_client_identity: entry_size = sizeof(struct distributed_client_identity); break;
case _distributed_message_vr_poses: entry_size = network_vr_entry_size(); break;
+ case _distributed_message_voice: entry_size = network_voice_entry_size(); break;
case _distributed_message_damage_events:
case _distributed_message_hit_reports: entry_size = network_damage_entry_size(header.type); break;
default: entry_size = network_objects_entry_size(header.type); break;
@@ -3590,6 +3594,16 @@ void network_distributed_handle_message(
/* (each kind from the host, or from a client; a VR player's hands
both ways) */
+ if (header.type == _distributed_message_voice)
+ {
+ if (machine_index == NONE ? game_connection() != _game_connection_network_client :
+ game_connection() != _game_connection_network_server)
+ {
+ return;
+ }
+ network_voice_handle(machine_index, message, size);
+ return;
+ }
if (header.type == _distributed_message_vr_poses)
{
if (machine_index == NONE ? game_connection() != _game_connection_network_client :
diff --git a/port/linux/game/network_distributed.h b/port/linux/game/network_distributed.h
index 084b4f71..81fd7f85 100644
--- a/port/linux/game/network_distributed.h
+++ b/port/linux/game/network_distributed.h
@@ -66,6 +66,10 @@ enum
the host, the host's of the others to each client (unreliable;
network_vr.c). Last, so that a build without it drops it. */
_distributed_message_vr_poses,
+ /* (the VR port's) a player's speech, every tick while they speak: a
+ client's own to the host, the host's and the other clients' to each
+ client (unreliable; network_voice.c) */
+ _distributed_message_voice,
NUMBER_OF_DISTRIBUTED_MESSAGES
};
@@ -236,4 +240,10 @@ void network_vr_tick(void);
word network_vr_entry_size(void);
void network_vr_handle_poses(long machine_index, void const *entries, short count);
+/* the voice chat (network_voice.c) */
+void network_voice_new_game(void);
+void network_voice_tick(void);
+word network_voice_entry_size(void);
+void network_voice_handle(long machine_index, void const *message, word size);
+
#endif // __NETWORK_DISTRIBUTED_H
diff --git a/port/linux/game/network_voice.c b/port/linux/game/network_voice.c
new file mode 100644
index 00000000..315398a0
--- /dev/null
+++ b/port/linux/game/network_voice.c
@@ -0,0 +1,326 @@
+/*
+NETWORK_VOICE.C
+
+The voice chat (the VR port's): an extension of the distributed netcode
+(port/linux/NETCODE.md, network_distributed.c), as the VR players' hands
+are (network_vr.c).
+
+- Every tick, a machine sends what its microphone heard of its player's
+ speech (halo_voice.h: Opus packets of 20 ms, only while they speak) in
+ _distributed_message_voice (unreliable): a client to the host, which
+ plays it and passes it on to its other clients; the host its own to
+ every client.
+- A machine plays each speaker's voice from where they are: a teammate
+ (in a game of teams) over the radio, as loud anywhere and only a little
+ to the side they are on; anyone else as near as they are (no further off
+ than VOICE_RANGE), from their side. A speaker with no unit (dead) is
+ heard over the radio by their team, and not by the rest.
+
+A machine that does not know the message (a build without this, of the same
+network version) drops it, as it drops any type past its own.
+*/
+
+#include "cseries.h"
+#include "game/game.h"
+#include "game/game_engine.h"
+#include "game/players.h"
+#include "units/units.h"
+#include "network_distributed.h"
+#include "halo_voice.h"
+#include "halo_vr.h"
+
+#include
+
+void platform_log(char const *format, ...);
+
+/* ---------- constants */
+
+enum
+{
+ /* an entry: the speaker, its sequence number and the packet's length,
+ and the packet */
+ VOICE_ENTRY_HEADER = 4,
+ VOICE_MAXIMUM_PACKET = 200,
+ VOICE_MAXIMUM_TAKEN = 1024,
+};
+
+/* how far off someone not on the radio is heard (world units: 60 metres) */
+#define VOICE_RANGE 20.0f
+/* how far to the side a voice is put: on the radio, and near */
+#define VOICE_RADIO_PAN 0.3f
+#define VOICE_NEAR_PAN 0.8f
+
+/* ---------- globals */
+
+static struct
+{
+ boolean active;
+ word sequence;
+ long sent;
+ long played;
+ long log_time;
+} network_voice;
+
+/* ---------- private code */
+
+static boolean network_voice_in_network_game(
+ void)
+{
+ short connection = game_connection();
+
+ return (connection == _game_connection_network_server || connection == _game_connection_network_client) &&
+ game_in_progress();
+}
+
+/* where a player's head is, and whether they have a unit */
+static boolean network_voice_player_head(
+ struct player_datum const *player,
+ real_point3d *head)
+{
+ long unit_index = distributed_living_unit(player);
+
+ if (unit_index == NONE)
+ return FALSE;
+ unit_get_camera_position(unit_index, head);
+ return TRUE;
+}
+
+/* the gains a speaker is heard at here (FALSE: not heard) */
+static boolean network_voice_gains(
+ struct player_datum const *speaker,
+ real *left,
+ real *right)
+{
+ long listener_index = local_player_get_player_index(0);
+ struct player_datum *listener = listener_index != NONE ? player_try_and_get(listener_index) : NULL;
+ boolean teammate = listener && game_engine_has_teams() && listener->team_index == speaker->team_index;
+ real_point3d speaker_head;
+ real_point3d listener_head;
+ real gain = 1.0f;
+ real pan = 0.0f;
+
+ if (!listener)
+ return FALSE;
+ if (!network_voice_player_head(speaker, &speaker_head))
+ {
+ /* (dead: on the radio, to the team) */
+ if (!teammate)
+ return FALSE;
+ *left = *right = 1.0f;
+ return TRUE;
+ }
+ if (network_voice_player_head(listener, &listener_head))
+ {
+ real dx = speaker_head.x - listener_head.x;
+ real dy = speaker_head.y - listener_head.y;
+ real dz = speaker_head.z - listener_head.z;
+ real distance = (real)sqrt(dx * dx + dy * dy + dz * dz);
+ real flat = (real)sqrt(dx * dx + dy * dy);
+ float look[3];
+ real forward_x;
+ real forward_y;
+ real length;
+ struct unit_datum *unit = unit_try_and_get(distributed_living_unit(listener));
+
+ /* (where the listener looks: the head, in VR) */
+ if (halo_vr_look_vector(0, look))
+ {
+ forward_x = look[0];
+ forward_y = look[1];
+ }
+ else
+ {
+ forward_x = unit ? unit->unit.looking_vector.i : 1.0f;
+ forward_y = unit ? unit->unit.looking_vector.j : 0.0f;
+ }
+ length = (real)sqrt(forward_x * forward_x + forward_y * forward_y);
+ if (length > 0.0001f && flat > 0.0001f)
+ {
+ /* (the right: forward turned clockwise) */
+ pan = (dx * forward_y - dy * forward_x) / (flat * length);
+ }
+ if (!teammate)
+ {
+ gain = 1.0f - distance / VOICE_RANGE;
+ if (gain <= 0.0f)
+ return FALSE;
+ gain *= gain;
+ }
+ pan *= teammate ? VOICE_RADIO_PAN : VOICE_NEAR_PAN;
+ }
+ else if (!teammate)
+ {
+ /* (the listener dead: the others as from where the body is not:
+ not at all) */
+ return FALSE;
+ }
+ *left = gain * (pan > 0.0f ? 1.0f - pan : 1.0f);
+ *right = gain * (pan < 0.0f ? 1.0f + pan : 1.0f);
+ return TRUE;
+}
+
+static void network_voice_play_entries(
+ byte const *entries,
+ byte const *end,
+ short count,
+ long machine_index)
+{
+ short index;
+
+ for (index = 0; index < count && entries + VOICE_ENTRY_HEADER <= end; index++)
+ {
+ byte player_byte = entries[0];
+ word sequence = (word)(entries[1] | entries[2] << 8);
+ short length = entries[3];
+ long player_index;
+ struct player_datum *player;
+ real left;
+ real right;
+
+ if (length <= 0 || entries + VOICE_ENTRY_HEADER + length > end)
+ break;
+ player_index = distributed_player_from_byte(player_byte);
+ player = player_index != NONE ? player_try_and_get(player_index) : NULL;
+ /* (the host: only a client's own players') */
+ if (player && (machine_index == NONE || distributed_machine_has_player(machine_index, player_byte)) &&
+ player->local_player_index == NONE && network_voice_gains(player, &left, &right))
+ {
+ halo_voice_play(player_byte, sequence, entries + VOICE_ENTRY_HEADER, length, left, right);
+ network_voice.played++;
+ }
+ entries += VOICE_ENTRY_HEADER + length;
+ }
+}
+
+/* ---------- public code */
+
+void network_voice_new_game(
+ void)
+{
+ network_voice.sequence = 0;
+}
+
+word network_voice_entry_size(
+ void)
+{
+ return VOICE_ENTRY_HEADER;
+}
+
+/* after each tick (network_distributed_tick): this machine's speech sent */
+void network_voice_tick(
+ void)
+{
+ struct
+ {
+ struct distributed_message_header header;
+ byte entries[VOICE_MAXIMUM_TAKEN + 64];
+ } message;
+ byte taken[VOICE_MAXIMUM_TAKEN];
+ long player_index = local_player_get_player_index(0);
+ long size;
+ long offset = 0;
+ long used = 0;
+ short count = 0;
+
+ if (!network_voice_in_network_game() || !halo_voice_enabled())
+ {
+ if (network_voice.active)
+ {
+ network_voice.active = FALSE;
+ halo_voice_end();
+ }
+ return;
+ }
+ if (!network_voice.active)
+ {
+ network_voice.active = TRUE;
+ halo_voice_begin();
+ }
+ if (game_time_get() - network_voice.log_time >= 150 && (network_voice.sent || network_voice.played))
+ {
+ platform_log("voice: sent %ld, played %ld packets (in 150 ticks)", network_voice.sent, network_voice.played);
+ network_voice.sent = network_voice.played = 0;
+ network_voice.log_time = game_time_get();
+ }
+ size = halo_voice_take(taken, sizeof(taken));
+ if (player_index == NONE)
+ return;
+ while (offset < size)
+ {
+ short length = taken[offset];
+
+ if (length <= 0 || offset + 1 + length > size || length > VOICE_MAXIMUM_PACKET)
+ break;
+ if (used + VOICE_ENTRY_HEADER + length > (long)sizeof(message.entries))
+ break;
+ message.entries[used] = distributed_player_to_byte(player_index);
+ message.entries[used + 1] = (byte)(network_voice.sequence & 0xFF);
+ message.entries[used + 2] = (byte)(network_voice.sequence >> 8);
+ message.entries[used + 3] = (byte)length;
+ csmemcpy(message.entries + used + VOICE_ENTRY_HEADER, taken + offset + 1, length);
+ used += VOICE_ENTRY_HEADER + length;
+ offset += 1 + length;
+ network_voice.sequence++;
+ count++;
+ }
+ if (!count)
+ return;
+ network_voice.sent += count;
+ distributed_send(&message, _distributed_message_voice, count, (word)(sizeof(message.header) + used),
+ game_connection() == _game_connection_network_server ? _distributed_to_clients : _distributed_to_host);
+}
+
+/* a machine's speech: on the host a client's, played and passed on to the
+other clients; on a client the host's of any other */
+void network_voice_handle(
+ long machine_index,
+ void const *message,
+ word size)
+{
+ struct distributed_message_header header;
+ byte const *entries = (byte const *)message + sizeof(header);
+ byte const *end = (byte const *)message + size;
+
+ if (!halo_voice_enabled() || !network_voice.active)
+ return;
+ csmemcpy(&header, message, sizeof(header));
+ network_voice_play_entries(entries, end, header.count, machine_index);
+ if (machine_index != NONE)
+ {
+ long machine_indices[HALO_PORT_MAXIMUM_NETWORK_MACHINES];
+ short machine_count = distributed_client_machines(machine_indices, HALO_PORT_MAXIMUM_NETWORK_MACHINES);
+ short number;
+ struct
+ {
+ struct distributed_message_header header;
+ byte entries[VOICE_MAXIMUM_TAKEN + 64];
+ } relayed;
+ word entries_size = (word)(end - entries);
+
+ byte const *entry = entries;
+ short index;
+
+ /* (only the sender's own players' speech is passed on, whole) */
+ if (entries_size < VOICE_ENTRY_HEADER || entries_size > sizeof(relayed.entries))
+ return;
+ for (index = 0; index < header.count; index++)
+ {
+ if (entry + VOICE_ENTRY_HEADER > end || entry[3] == 0 || entry + VOICE_ENTRY_HEADER + entry[3] > end ||
+ !distributed_machine_has_player(machine_index, entry[0]))
+ {
+ return;
+ }
+ entry += VOICE_ENTRY_HEADER + entry[3];
+ }
+ if (entry != end)
+ return;
+ for (number = 0; number < machine_count; number++)
+ {
+ if (machine_indices[number] == machine_index)
+ continue;
+ csmemcpy(relayed.entries, entries, entries_size);
+ distributed_send_to_machine(machine_indices[number], &relayed, _distributed_message_voice, header.count,
+ (word)(sizeof(relayed.header) + entries_size));
+ }
+ }
+}
diff --git a/port/linux/include/halo_voice.h b/port/linux/include/halo_voice.h
new file mode 100644
index 00000000..0712a208
--- /dev/null
+++ b/port/linux/include/halo_voice.h
@@ -0,0 +1,31 @@
+/*
+HALO_VOICE.H
+
+The voice chat's sound for the game (port/linux/src/voice.c: on Android the
+host's microphone, Opus and mixer, port/android/host/host_voice.c; elsewhere
+nothing). port/linux/game/network_voice.c sends and receives it.
+*/
+
+#ifndef __HALO_VOICE_H
+#define __HALO_VOICE_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* whether the voice chat is on (voice.enabled) */
+int halo_voice_enabled(void);
+/* starts playing voices, and the microphone if voice.microphone; stops */
+int halo_voice_begin(void);
+void halo_voice_end(void);
+/* the microphone's speech since asked: Opus packets, each after a byte of
+its length; returns the bytes */
+int halo_voice_take(void *buffer, int size);
+/* a speaker's packet, played at these gains (left, right) */
+void halo_voice_play(int speaker, int sequence, const void *data, int length, float left, float right);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c
index 2b5a5e10..64c69b1f 100644
--- a/port/linux/src/port_config.c
+++ b/port/linux/src/port_config.c
@@ -275,6 +275,17 @@ static const struct config_setting config_settings[] =
"clipboard) that lets whoever has it join over the internet; opening a\n"
"link (or copying one before switching to the game) joins. Only people\n"
"with the invite can join. Off keeps system link to the local network." },
+ { "voice.enabled", _config_boolean, "true", "HALO_VOICE_ENABLED", _environment_value, _platform_android,
+ "Voice chat in multiplayer: hear the other players (teammates over the\n"
+ "radio, the rest when near, from where they are)." },
+ { "voice.microphone", _config_boolean, "true", "HALO_VOICE_MICROPHONE", _environment_value, _platform_android,
+ "Send your voice in multiplayer, whenever you speak (the headset's\n"
+ "microphone; Android asks for it once). False only listens." },
+ { "voice.volume", _config_real, "1.0", "HALO_VOICE_VOLUME", _environment_value, _platform_android,
+ "The other players' voices' volume (1 as they come)." },
+ { "voice.threshold", _config_real, "0.02", "HALO_VOICE_THRESHOLD", _environment_value, _platform_android,
+ "How loud the microphone must be to send (0..1 of full scale): higher\n"
+ "if it sends noise, lower if it cuts words off." },
{ "network.public_game", _config_boolean, "false", "HALO_NET_PUBLIC_GAME", _environment_value, _platform_all,
"Host games as public: listed for anyone among the online games (the\n"
"system link screen's X), as Y in a hosted game's lobby makes them." },
diff --git a/port/linux/src/voice.c b/port/linux/src/voice.c
new file mode 100644
index 00000000..ed7b0103
--- /dev/null
+++ b/port/linux/src/voice.c
@@ -0,0 +1,87 @@
+/*
+VOICE.C
+
+The voice chat's sound (halo_voice.h): on Android, the host's
+(port/android/host/host_voice.c); elsewhere none.
+*/
+
+#include "halo_voice.h"
+#include "platform.h"
+#include "port_config.h"
+
+#ifdef HALO_ANDROID
+#include "guest_host.h"
+
+#include
+
+static int voice_started;
+
+/* whether the app may use the microphone, as the launcher found
+(LauncherActivity.java): opened without it, Android would ask in the middle
+of the game */
+static int voice_microphone_allowed(void)
+{
+ char path[512];
+ FILE *file;
+ int allowed = 0;
+
+ snprintf(path, sizeof(path), "%s/microphone_permission.txt", platform_data_root());
+ file = fopen(path, "rb");
+ if (file)
+ {
+ allowed = fgetc(file) == '1';
+ fclose(file);
+ }
+ return allowed;
+}
+
+int halo_voice_enabled(void)
+{
+ return config_boolean("voice.enabled");
+}
+
+int halo_voice_begin(void)
+{
+ int microphone;
+
+ if (voice_started)
+ return 1;
+ voice_started = 1;
+ microphone = config_boolean("voice.microphone") && voice_microphone_allowed();
+ platform_log("voice: on, %s", microphone ? "with the microphone" :
+ config_boolean("voice.microphone") ? "without the microphone (not allowed)" : "listening only");
+ return host_voice_start(microphone, (float)config_real("voice.volume"), (float)config_real("voice.threshold"));
+}
+
+void halo_voice_end(void)
+{
+ if (!voice_started)
+ return;
+ voice_started = 0;
+ host_voice_stop();
+ platform_log("voice: off");
+}
+
+int halo_voice_take(void *buffer, int size)
+{
+ return voice_started ? host_voice_take_packets(buffer, size) : 0;
+}
+
+void halo_voice_play(int speaker, int sequence, const void *data, int length, float left, float right)
+{
+ if (voice_started)
+ host_voice_play(speaker, sequence, data, length, left, right);
+}
+
+#else
+
+int halo_voice_enabled(void) { return 0; }
+int halo_voice_begin(void) { return 0; }
+void halo_voice_end(void) {}
+int halo_voice_take(void *buffer, int size) { (void)buffer; (void)size; return 0; }
+void halo_voice_play(int speaker, int sequence, const void *data, int length, float left, float right)
+{
+ (void)speaker; (void)sequence; (void)data; (void)length; (void)left; (void)right;
+}
+
+#endif
diff --git a/tools/android_build.py b/tools/android_build.py
index 254653db..ec5b3080 100644
--- a/tools/android_build.py
+++ b/tools/android_build.py
@@ -44,6 +44,10 @@ MUSL_URL = f"https://musl.libc.org/releases/musl-{MUSL_VERSION}.tar.gz"
SDL_TAG = "release-3.4.16"
SDL_DIR = THIRD_PARTY / "SDL3"
SDL_URL = "https://github.com/libsdl-org/SDL.git"
+# Opus, the voice chat's codec (host_voice.c), built into the host library
+OPUS_TAG = "v1.5.2"
+OPUS_DIR = THIRD_PARTY / "opus"
+OPUS_URL = "https://github.com/xiph/opus.git"
# the Khronos OpenXR loader for Android (the VR build, host_xr.c): its
# headers and library are linked from here; the app packages the same AAR
# through Gradle (port/android/app/build.gradle), which also merges the
@@ -178,6 +182,10 @@ def fetch_third_party() -> None:
print(f"Cloning SDL3 {SDL_TAG}")
subprocess.run(["git", "clone", "-q", "--depth", "1", "--branch", SDL_TAG, SDL_URL, str(SDL_DIR)],
check=True)
+ if not OPUS_DIR.is_dir():
+ print(f"Cloning Opus {OPUS_TAG}")
+ subprocess.run(["git", "clone", "-q", "--depth", "1", "--branch", OPUS_TAG, OPUS_URL, str(OPUS_DIR)],
+ check=True)
if not OPENXR_DIR.is_dir():
print(f"Downloading {OPENXR_URL}")
import zipfile
@@ -509,6 +517,23 @@ def generate_android_build(n: Writer, sln: Any) -> None:
)
n.build(outputs=libsdl, rule="android_sdl3", implicit=[SDL_DIR / "CMakeLists.txt"])
+ # ---------- Opus (the voice chat's codec, host_voice.c)
+
+ opus_build = BUILD / "opus-build"
+ libopus = opus_build / "libopus.a"
+ n.rule(
+ name="android_opus",
+ command=(f"cmake -S {OPUS_DIR} -B {opus_build} -G Ninja "
+ f"-DCMAKE_TOOLCHAIN_FILE={ndk}/build/cmake/android.toolchain.cmake "
+ f"-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-{ANDROID_API} -DCMAKE_BUILD_TYPE=Release "
+ f"-DCMAKE_POSITION_INDEPENDENT_CODE=ON -DOPUS_BUILD_SHARED_LIBRARY=OFF -DOPUS_BUILD_TESTING=OFF "
+ f"-DOPUS_BUILD_PROGRAMS=OFF -DOPUS_INSTALL_PKG_CONFIG_MODULE=OFF -DOPUS_INSTALL_CMAKE_CONFIG_MODULE=OFF "
+ f"> {BUILD}/opus-configure.log && ninja -C {opus_build} > {BUILD}/opus-build.log"),
+ description="ANDROID OPUS",
+ pool="console",
+ )
+ n.build(outputs=libopus, rule="android_opus", implicit=[OPUS_DIR / "CMakeLists.txt"])
+
# ---------- the host library
host_objects: List[Path] = []
@@ -523,7 +548,7 @@ def generate_android_build(n: Writer, sln: Any) -> None:
host_cflags = " ".join([
"-O2", "-g", "-fPIC", "-Wall", "-Wno-unused-function", "-D_GNU_SOURCE",
f"-I{PORT_DIR}/include", f"-I{PORT_DIR}/host", f"-I{SDL_DIR}/include", f"-I{LINUX_DIR}/src",
- f"-I{TOML_DIR}", f"-I{OPENXR_DIR}/include",
+ f"-I{TOML_DIR}", f"-I{OPENXR_DIR}/include", f"-I{OPUS_DIR}/include",
])
host_sources = sorted((PORT_DIR / "host").glob("*.c")) + [
LINUX_DIR / "src" / "posix_files.c", LINUX_DIR / "src" / "posix_net.c",
@@ -549,12 +574,12 @@ def generate_android_build(n: Writer, sln: Any) -> None:
host_objects.append(table_obj)
n.rule(
name="android_host_link",
- command=(f"$android_host_cc -shared -o $out $in -L{sdl_build} -L{OPENXR_DIR}/lib -lopenxr_loader "
+ command=(f"$android_host_cc -shared -o $out $in {libopus} -L{sdl_build} -L{OPENXR_DIR}/lib -lopenxr_loader "
+ " ".join(f"-l{lib}" for lib in HOST_LIBRARIES)
+ " -Wl,-z,max-page-size=16384 -Wl,--no-undefined"),
description="ANDROID HOST LINK $out",
)
- n.build(outputs=libmain, rule="android_host_link", inputs=host_objects, implicit=[libsdl])
+ n.build(outputs=libmain, rule="android_host_link", inputs=host_objects, implicit=[libsdl, libopus])
# ---------- staging for Gradle