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