Update
This commit is contained in:
120
.gitignore
vendored
Normal file
120
.gitignore
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
# User-specific stuff
|
||||
.idea/
|
||||
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
.gradle
|
||||
build/
|
||||
|
||||
# Ignore Gradle GUI config
|
||||
gradle-app.setting
|
||||
|
||||
# Cache of project
|
||||
.gradletasknamecache
|
||||
|
||||
**/build/
|
||||
|
||||
# Common working directory
|
||||
run/
|
||||
runs/
|
||||
|
||||
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
|
||||
!gradle-wrapper.jar
|
||||
/remappedSrc/
|
||||
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
95
build.gradle
Normal file
95
build.gradle
Normal file
@@ -0,0 +1,95 @@
|
||||
plugins {
|
||||
id 'fabric-loom' version '1.12.0-alpha.19'
|
||||
id 'maven-publish'
|
||||
}
|
||||
|
||||
version = project.mod_version
|
||||
group = project.maven_group
|
||||
|
||||
base {
|
||||
archivesName = project.archives_base_name
|
||||
}
|
||||
|
||||
|
||||
fabricApi {
|
||||
configureDataGeneration {
|
||||
client = true
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
// Add repositories to retrieve artifacts from in here.
|
||||
// You should only use this when depending on other mods because
|
||||
// Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
|
||||
// See https://docs.gradle.org/current/userguide/declaring_repositories.html
|
||||
// for more information about repositories.
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// To change the versions see the gradle.properties file
|
||||
minecraft "com.mojang:minecraft:${project.minecraft_version}"
|
||||
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
|
||||
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
||||
|
||||
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
|
||||
}
|
||||
|
||||
processResources {
|
||||
inputs.property "version", project.version
|
||||
inputs.property "minecraft_version", project.minecraft_version
|
||||
inputs.property "loader_version", project.loader_version
|
||||
filteringCharset "UTF-8"
|
||||
|
||||
filesMatching("fabric.mod.json") {
|
||||
expand "version": project.version,
|
||||
"minecraft_version": project.minecraft_version,
|
||||
"loader_version": project.loader_version
|
||||
}
|
||||
}
|
||||
|
||||
def targetJavaVersion = 21
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
// ensure that the encoding is set to UTF-8, no matter what the system default is
|
||||
// this fixes some edge cases with special characters not displaying correctly
|
||||
// see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html
|
||||
// If Javadoc is generated, this must be specified in that task too.
|
||||
it.options.encoding = "UTF-8"
|
||||
if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) {
|
||||
it.options.release.set(targetJavaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
java {
|
||||
def javaVersion = JavaVersion.toVersion(targetJavaVersion)
|
||||
if (JavaVersion.current() < javaVersion) {
|
||||
toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion)
|
||||
}
|
||||
// Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
|
||||
// if it is present.
|
||||
// If you remove this line, sources will not be generated.
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
jar {
|
||||
from("LICENSE") {
|
||||
rename { "${it}_${project.archivesBaseName}" }
|
||||
}
|
||||
}
|
||||
|
||||
// configure the maven publication
|
||||
publishing {
|
||||
publications {
|
||||
create("mavenJava", MavenPublication) {
|
||||
artifactId = project.archives_base_name
|
||||
from components.java
|
||||
}
|
||||
}
|
||||
|
||||
// See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
|
||||
repositories {
|
||||
// Add repositories to publish to here.
|
||||
// Notice: This block does NOT have the same function as the block in the top level.
|
||||
// The repositories here will be used for publishing your artifact, not for
|
||||
// retrieving dependencies.
|
||||
}
|
||||
}
|
||||
14
gradle.properties
Normal file
14
gradle.properties
Normal file
@@ -0,0 +1,14 @@
|
||||
# Done to increase the memory available to gradle.
|
||||
org.gradle.jvmargs=-Xmx1G
|
||||
# Fabric Properties
|
||||
# check these on https://modmuss50.me/fabric.html
|
||||
minecraft_version=1.21.10
|
||||
yarn_mappings=1.21.10+build.2
|
||||
loader_version=0.17.3
|
||||
# Mod Properties
|
||||
mod_version=3.2.1
|
||||
maven_group=systems.brn
|
||||
archives_base_name=textvoice
|
||||
# Dependencies
|
||||
# check this on https://modmuss50.me/fabric.html
|
||||
fabric_version=0.136.0+1.21.10
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
gradlew
vendored
Executable file
251
gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
gradlew.bat
vendored
Normal file
94
gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
9
settings.gradle
Normal file
9
settings.gradle
Normal file
@@ -0,0 +1,9 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven {
|
||||
name = 'Fabric'
|
||||
url = 'https://maven.fabricmc.net/'
|
||||
}
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
11
src/main/java/systems/brn/textvoice/client/Codec2Modes.java
Normal file
11
src/main/java/systems/brn/textvoice/client/Codec2Modes.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
public class Codec2Modes {
|
||||
public static final int CODEC2_MODE_3200 = 0;
|
||||
public static final int CODEC2_MODE_2400 = 1;
|
||||
public static final int CODEC2_MODE_1600 = 2;
|
||||
public static final int CODEC2_MODE_1400 = 3;
|
||||
public static final int CODEC2_MODE_1300 = 4;
|
||||
public static final int CODEC2_MODE_1200 = 5;
|
||||
public static final int CODEC2_MODE_700C = 8;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
public class Codec2Wrapper {
|
||||
|
||||
final public long handle;
|
||||
|
||||
public Codec2Wrapper(int mode) {
|
||||
handle = initCodec2(mode);
|
||||
}
|
||||
|
||||
public native long initCodec2(int mode);
|
||||
public native void destroyCodec2(long handle);
|
||||
public native byte[] encodeFrame(long handle, short[] pcmSamples);
|
||||
public native short[] decodeFrame(long handle, byte[] codec2Frame);
|
||||
|
||||
public void close() {
|
||||
destroyCodec2(handle);
|
||||
}
|
||||
}
|
||||
32
src/main/java/systems/brn/textvoice/client/NativeLoader.java
Normal file
32
src/main/java/systems/brn/textvoice/client/NativeLoader.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
public class NativeLoader {
|
||||
|
||||
public static void loadCodec2() throws Exception {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
String path;
|
||||
|
||||
if (os.contains("win")) path = "/native/windows/codec2jni.dll";
|
||||
else path = "/native/linux/libcodec2jni.so";
|
||||
|
||||
// Extract to temp file
|
||||
InputStream in = NativeLoader.class.getResourceAsStream(path);
|
||||
if (in == null) throw new RuntimeException("Native library not found: " + path);
|
||||
|
||||
File temp = File.createTempFile("codec2jni", os.contains("win") ? ".dll" : ".so");
|
||||
temp.deleteOnExit();
|
||||
|
||||
try (FileOutputStream out = new FileOutputStream(temp)) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) out.write(buffer, 0, read);
|
||||
}
|
||||
|
||||
// Load the library
|
||||
System.load(temp.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
import systems.brn.textvoice.client.audio.Speaker;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class PlayerAudioMixer {
|
||||
|
||||
private final Speaker speaker;
|
||||
private final Map<String, Queue<short[]>> streams = new ConcurrentHashMap<>();
|
||||
private final int frameSamples = 160;
|
||||
private final int sampleRate = 8000;
|
||||
private final Map<String, PlayerHUDState> hudStates;
|
||||
|
||||
// Master volume in 0-100 range
|
||||
private volatile short masterVolume = 100;
|
||||
|
||||
public PlayerAudioMixer(Speaker speaker, Map<String, PlayerHUDState> hudStates) {
|
||||
this.speaker = speaker;
|
||||
this.hudStates = hudStates;
|
||||
}
|
||||
|
||||
public void setMasterVolume(short volume) {
|
||||
if (volume < 0) volume = 0;
|
||||
if (volume > 100) volume = 100;
|
||||
this.masterVolume = volume;
|
||||
}
|
||||
|
||||
public void enqueuePlayerFrame(String playerName, short[] pcm) {
|
||||
Queue<short[]> queue = streams.computeIfAbsent(playerName, k -> new LinkedList<>());
|
||||
synchronized (queue) {
|
||||
queue.add(pcm);
|
||||
}
|
||||
}
|
||||
|
||||
public void start() {
|
||||
Thread thread = new Thread(() -> {
|
||||
short[] outputBuffer = new short[frameSamples]; // 32-bit int output
|
||||
long nextFrameTime = System.nanoTime();
|
||||
|
||||
while (true) {
|
||||
|
||||
float[] mixBuffer = new float[frameSamples];
|
||||
Arrays.fill(mixBuffer, 0.0f);
|
||||
|
||||
for (Map.Entry<String, Queue<short[]>> entry : streams.entrySet()) {
|
||||
String playerName = entry.getKey();
|
||||
Queue<short[]> queue = entry.getValue();
|
||||
|
||||
short[] frame = null;
|
||||
synchronized (queue) {
|
||||
if (!queue.isEmpty()) frame = queue.poll();
|
||||
}
|
||||
|
||||
if (frame != null) {
|
||||
PlayerHUDState state = hudStates.computeIfAbsent(playerName, k -> new PlayerHUDState());
|
||||
float playerVolume = state.volume / 100f; // per-player volume 0.0–1.0
|
||||
|
||||
for (int i = 0; i < Math.min(frame.length, mixBuffer.length); i++) {
|
||||
mixBuffer[i] += frame[i] * playerVolume; // apply per-player volume
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply master volume and convert to 32-bit int
|
||||
float masterVol = masterVolume / 100f;
|
||||
|
||||
for (int i = 0; i < frameSamples; i++) {
|
||||
float sample = mixBuffer[i] * masterVol; // apply master volume
|
||||
|
||||
// clamp to 16-bit PCM range
|
||||
if (sample > 32767f) sample = 32767f;
|
||||
if (sample < -32768f) sample = -32768f;
|
||||
|
||||
outputBuffer[i] = (short) sample;
|
||||
}
|
||||
|
||||
|
||||
speaker.play(outputBuffer);
|
||||
|
||||
// Schedule next frame (20ms)
|
||||
nextFrameTime += (long) (frameSamples / (double) sampleRate * 1_000_000_000);
|
||||
long sleepTime = (nextFrameTime - System.nanoTime()) / 1_000_000;
|
||||
if (sleepTime > 0) {
|
||||
try { Thread.sleep(sleepTime); } catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
}, "textvoice-Mixer");
|
||||
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
public class PlayerHUDState {
|
||||
public long lastFrameTime = 0; // when last frame was received
|
||||
public long playingTo = 0; // when last frame was received
|
||||
public int volume = 100;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class PlayerReceiveBuffer {
|
||||
public final List<short[]> buffer = new ArrayList<>();
|
||||
public boolean playOnRelease = false; // PTT control
|
||||
}
|
||||
54
src/main/java/systems/brn/textvoice/client/SafeBMPCodec.java
Normal file
54
src/main/java/systems/brn/textvoice/client/SafeBMPCodec.java
Normal file
@@ -0,0 +1,54 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class SafeBMPCodec {
|
||||
private static final int OFFSET = 0x00A8; // start of safe BMP range
|
||||
private static final int BITS_PER_CHAR = 15;
|
||||
|
||||
public static String encode(byte[] data) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int bitBuffer = 0;
|
||||
int bitCount = 0;
|
||||
|
||||
for (byte b : data) {
|
||||
bitBuffer = (bitBuffer << 8) | (b & 0xFF);
|
||||
bitCount += 8;
|
||||
|
||||
while (bitCount >= BITS_PER_CHAR) {
|
||||
bitCount -= BITS_PER_CHAR;
|
||||
int value = (bitBuffer >> bitCount) & 0x7FFF; // 15 bits
|
||||
sb.append((char) (value + OFFSET));
|
||||
}
|
||||
}
|
||||
|
||||
if (bitCount > 0) {
|
||||
int value = (bitBuffer << (BITS_PER_CHAR - bitCount)) & 0x7FFF;
|
||||
sb.append((char) (value + OFFSET));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static byte[] decode(String s) {
|
||||
int bitBuffer = 0;
|
||||
int bitCount = 0;
|
||||
byte[] temp = new byte[s.length() * 2]; // max possible size
|
||||
int pos = 0;
|
||||
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
int value = s.charAt(i) - OFFSET;
|
||||
bitBuffer = (bitBuffer << BITS_PER_CHAR) | (value & 0x7FFF);
|
||||
bitCount += BITS_PER_CHAR;
|
||||
|
||||
while (bitCount >= 8) {
|
||||
bitCount -= 8;
|
||||
temp[pos++] = (byte) ((bitBuffer >> bitCount) & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
return Arrays.copyOf(temp, pos);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
315
src/main/java/systems/brn/textvoice/client/TextVoiceClient.java
Normal file
315
src/main/java/systems/brn/textvoice/client/TextVoiceClient.java
Normal file
@@ -0,0 +1,315 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.sun.jdi.connect.Connector;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager;
|
||||
import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
|
||||
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
|
||||
import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements;
|
||||
import net.minecraft.client.MinecraftClient;
|
||||
import net.minecraft.client.gui.DrawContext;
|
||||
import net.minecraft.client.network.ClientPlayerEntity;
|
||||
import net.minecraft.client.option.KeyBinding;
|
||||
import net.minecraft.client.render.RenderTickCounter;
|
||||
import net.minecraft.client.util.InputUtil;
|
||||
import net.minecraft.client.util.math.MatrixStack;
|
||||
import net.minecraft.command.argument.EntityArgumentType;
|
||||
import net.minecraft.text.Text;
|
||||
import net.minecraft.util.Identifier;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
import systems.brn.textvoice.client.audio.Microphone;
|
||||
import systems.brn.textvoice.client.audio.Speaker;
|
||||
|
||||
import javax.sound.sampled.*;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class TextVoiceClient implements ClientModInitializer {
|
||||
|
||||
private Microphone microphone;
|
||||
private TextVoiceHandler messageHandler;
|
||||
|
||||
private KeyBinding pttKey;
|
||||
private volatile boolean pttHeld = false;
|
||||
public static String MOD_ID = "textvoice";
|
||||
|
||||
public final Map<String, PlayerHUDState> hudStates = new ConcurrentHashMap<>();
|
||||
|
||||
public final Map<String, PlayerReceiveBuffer> receiveBuffers = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
private static boolean hudAttached = false;
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
try {
|
||||
NativeLoader.loadCodec2();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
microphone = new Microphone(8000, 160);
|
||||
microphone.open(null);
|
||||
|
||||
Speaker speaker = new Speaker(8000);
|
||||
speaker.open(null);
|
||||
|
||||
PlayerAudioMixer mixer = new PlayerAudioMixer(speaker, hudStates);
|
||||
mixer.start();
|
||||
|
||||
messageHandler = new TextVoiceHandler(mixer, receiveBuffers, hudStates);
|
||||
|
||||
final KeyBinding.Category TEST_CATEGORY = KeyBinding.Category.create(Identifier.of("textvoice", "main"));
|
||||
|
||||
// PTT key (default: left control)
|
||||
pttKey = KeyBindingHelper.registerKeyBinding(new KeyBinding(
|
||||
"key.textvoice.ptt",
|
||||
InputUtil.Type.KEYSYM,
|
||||
GLFW.GLFW_KEY_F7,
|
||||
TEST_CATEGORY
|
||||
));
|
||||
|
||||
|
||||
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> {
|
||||
dispatcher.register(ClientCommandManager.literal("vcplayer")
|
||||
.then(ClientCommandManager.argument("player", EntityArgumentType.player())
|
||||
.executes(context -> {
|
||||
// Autocomplete works, but we can only safely get the name string
|
||||
String targetName = context.getInput().split(" ", 2)[1];
|
||||
|
||||
messageHandler.setTargetPlayer(targetName);
|
||||
|
||||
ClientPlayerEntity player = MinecraftClient.getInstance().player;
|
||||
if (player != null) {
|
||||
player.sendMessage(Text.translatable("gui.textvoice.targetset", targetName), false);
|
||||
}
|
||||
return 1;
|
||||
})
|
||||
)
|
||||
);
|
||||
dispatcher.register(ClientCommandManager.literal("vcaudio")
|
||||
.then(ClientCommandManager.argument("input", StringArgumentType.string()) // allow spaces
|
||||
.suggests((context, builder) -> {
|
||||
// Suggest input devices (microphones)
|
||||
Mixer.Info[] mixers = AudioSystem.getMixerInfo();
|
||||
Arrays.stream(mixers)
|
||||
.filter(m -> AudioSystem.getMixer(m).getTargetLineInfo().length > 0)
|
||||
.map(Mixer.Info::getName)
|
||||
.forEach(name -> builder.suggest("\"" + name + "\""));
|
||||
return builder.buildFuture();
|
||||
})
|
||||
.then(ClientCommandManager.argument("output", StringArgumentType.string()) // allow spaces
|
||||
.suggests((context, builder) -> {
|
||||
// Suggest output devices (speakers)
|
||||
Mixer.Info[] mixers = AudioSystem.getMixerInfo();
|
||||
Arrays.stream(mixers)
|
||||
.filter(m -> AudioSystem.getMixer(m).getSourceLineInfo().length > 0)
|
||||
.map(Mixer.Info::getName)
|
||||
.forEach(name -> builder.suggest("\"" + name + "\""));
|
||||
return builder.buildFuture();
|
||||
})
|
||||
.executes(context -> {
|
||||
String inputDevice = StringArgumentType.getString(context, "input").replaceAll("^\"|\"$", "");
|
||||
String outputDevice = StringArgumentType.getString(context, "output").replaceAll("^\"|\"$", "");
|
||||
|
||||
// Find Mixer.Info by name
|
||||
Mixer.Info inputMixer = Arrays.stream(AudioSystem.getMixerInfo())
|
||||
.filter(m -> m.getName().equals(inputDevice))
|
||||
.findFirst().orElse(null);
|
||||
|
||||
Mixer.Info outputMixer = Arrays.stream(AudioSystem.getMixerInfo())
|
||||
.filter(m -> m.getName().equals(outputDevice))
|
||||
.findFirst().orElse(null);
|
||||
|
||||
ClientPlayerEntity player = MinecraftClient.getInstance().player;
|
||||
try {
|
||||
microphone.setDevice(inputMixer);
|
||||
speaker.setDevice(outputMixer);
|
||||
if (player != null) {
|
||||
player.sendMessage(Text.translatable(
|
||||
"gui.textvoice.audiodevselected", microphone.getCurrentMixer().getName(), speaker.getCurrentMixer().getName()
|
||||
), false);
|
||||
}
|
||||
} catch (LineUnavailableException e) {
|
||||
if (player != null) {
|
||||
player.sendMessage(Text.translatable(
|
||||
"gui.textvoice.audiodevselecterror", e.getMessage()
|
||||
), false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// TODO: store these devices in your mic/speaker handling classes
|
||||
return 1;
|
||||
})
|
||||
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
dispatcher.register(ClientCommandManager.literal("vcplayerbroadcast")
|
||||
.executes(context -> {
|
||||
|
||||
messageHandler.setTargetPlayer("*");
|
||||
|
||||
ClientPlayerEntity player = MinecraftClient.getInstance().player;
|
||||
if (player != null) {
|
||||
player.sendMessage(Text.translatable("gui.textvoice.targetsetbroadcast"), false);
|
||||
}
|
||||
return 1;
|
||||
})
|
||||
);
|
||||
|
||||
dispatcher.register(ClientCommandManager.literal("vcmaster")
|
||||
.then(ClientCommandManager.argument("volume", IntegerArgumentType.integer(0,100))
|
||||
.executes(context -> {
|
||||
// Autocomplete works, but we can only safely get the name string
|
||||
int volume = IntegerArgumentType.getInteger(context, "volume");
|
||||
mixer.setMasterVolume((short) volume);
|
||||
return 1;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
dispatcher.register(ClientCommandManager.literal("vcvolume")
|
||||
.then(ClientCommandManager.argument("player", EntityArgumentType.player())
|
||||
.then(ClientCommandManager.argument("volume", IntegerArgumentType.integer(0,100))
|
||||
.executes(context -> {
|
||||
// Autocomplete works, but we can only safely get the name string
|
||||
String targetName = context.getInput().split(" ", 5)[1];
|
||||
hudStates.computeIfAbsent(targetName, k -> new PlayerHUDState()).volume = IntegerArgumentType.getInteger(context, "volume");
|
||||
return 1;
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
if(!hudAttached) {
|
||||
HudElementRegistry.attachElementBefore(VanillaHudElements.CHAT, Identifier.of(MOD_ID, "before_chat"), this::hudRender);
|
||||
hudAttached = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Mic capture thread
|
||||
Thread micThread = new Thread(this::micLoop, "textvoice-Mic");
|
||||
micThread.setDaemon(true);
|
||||
micThread.start();
|
||||
|
||||
// Key check thread
|
||||
Thread keyThread = new Thread(this::keyLoop, "textvoice-PTT-Key");
|
||||
keyThread.setDaemon(true);
|
||||
keyThread.start();
|
||||
|
||||
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> {
|
||||
// Hide or disable your element
|
||||
HudElementRegistry.removeElement(Identifier.of(MOD_ID, "before_chat"));
|
||||
hudStates.clear(); // clear all tracking state
|
||||
});
|
||||
|
||||
// Hook into chat events
|
||||
ClientReceiveMessageEvents.ALLOW_CHAT.register(messageHandler::onChatMessage);
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
private void hudRender(DrawContext drawContext, RenderTickCounter renderTickCounter) {
|
||||
MinecraftClient client = MinecraftClient.getInstance();
|
||||
if (client.player == null) return;
|
||||
|
||||
int x = 5; // HUD start X
|
||||
final int[] y = {5}; // HUD start Y
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long activeThreshold = 2000; // only show players with frames in last 2 seconds
|
||||
long displayThreshold = 60000; // only show players with frames in last 60 seconds
|
||||
|
||||
hudStates.forEach((playerName, state) -> {
|
||||
// Skip inactive players
|
||||
if (now - state.lastFrameTime > displayThreshold && state.playingTo <= System.currentTimeMillis()) return;
|
||||
|
||||
// Active playing highlight
|
||||
int baseColor = (now - state.lastFrameTime) < 100 ? 0xFFFF0000 : ((state.playingTo > System.currentTimeMillis()) ? 0xFF00FF00 : 0x88FFFFFF); // green if playing, white if idle
|
||||
|
||||
int width = 160;
|
||||
int height = 12;
|
||||
|
||||
drawContext.fill(x, y[0], x + width, y[0] + height, baseColor);
|
||||
|
||||
// Draw player name
|
||||
|
||||
PlayerReceiveBuffer buffer = receiveBuffers.getOrDefault(playerName, new PlayerReceiveBuffer());
|
||||
|
||||
int totalSamples = buffer.buffer.stream().mapToInt(f -> f.length).sum();
|
||||
|
||||
double timePlaying = (state.playingTo - System.currentTimeMillis()) / 1000.0;
|
||||
|
||||
timePlaying = Math.max(timePlaying, 0);
|
||||
|
||||
double timeBuffered = totalSamples / 8000.0;
|
||||
|
||||
timeBuffered = Math.max(timeBuffered, 0);
|
||||
|
||||
drawContext.drawText(client.textRenderer, playerName + " " + state.volume + "% " + timePlaying + " (" + timeBuffered + ")", x + 2, y[0] + 1, 0xFFFFFFFF, false);
|
||||
|
||||
// Frame activity indicator (red bar)
|
||||
float frameDelta = Math.min(1f, (now - state.lastFrameTime) / (float)activeThreshold);
|
||||
int frameWidth = (int)((1f - frameDelta) * (width - 4));
|
||||
drawContext.fill(x + 2, y[0] + height - 4, x + 2 + frameWidth, y[0] + height - 2, 0xFFFF0000);
|
||||
|
||||
y[0] += height + 4; // move to next player
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void micLoop() {
|
||||
while (true) {
|
||||
try {
|
||||
short[] pcm = microphone.readFrame();
|
||||
if (pttHeld && pcm.length > 0) {
|
||||
messageHandler.bufferPCM(pcm);
|
||||
}
|
||||
Thread.sleep(20); // capture ~50 fps
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void keyLoop() {
|
||||
while (true) {
|
||||
boolean pressed = pttKey.isPressed();
|
||||
if (pressed != pttHeld) {
|
||||
pttHeld = pressed;
|
||||
if (pttHeld) {
|
||||
ClientPlayerEntity player = MinecraftClient.getInstance().player;
|
||||
if(messageHandler.targetPlayer == null || messageHandler.targetPlayer.isBlank()) {
|
||||
if (player != null) {
|
||||
player.sendMessage(Text.translatable("gui.textvoice.notarget"), true);
|
||||
}
|
||||
} else {
|
||||
if (player != null) {
|
||||
player.sendMessage(Text.translatable("gui.textvoice.targettx", messageHandler.targetPlayer), true);
|
||||
}
|
||||
}
|
||||
messageHandler.startPTT(); // start sending while held
|
||||
} else {
|
||||
messageHandler.stopPTT(); // finish flushing on release
|
||||
}
|
||||
}
|
||||
try {
|
||||
Thread.sleep(20);
|
||||
} catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint;
|
||||
import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator;
|
||||
|
||||
public class TextVoiceDataGenerator implements DataGeneratorEntrypoint {
|
||||
|
||||
@Override
|
||||
public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) {
|
||||
FabricDataGenerator.Pack pack = fabricDataGenerator.createPack();
|
||||
}
|
||||
}
|
||||
260
src/main/java/systems/brn/textvoice/client/TextVoiceHandler.java
Normal file
260
src/main/java/systems/brn/textvoice/client/TextVoiceHandler.java
Normal file
@@ -0,0 +1,260 @@
|
||||
package systems.brn.textvoice.client;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.client.MinecraftClient;
|
||||
import net.minecraft.client.network.ClientPlayerEntity;
|
||||
import net.minecraft.network.message.MessageType;
|
||||
import net.minecraft.network.message.SignedMessage;
|
||||
import net.minecraft.text.Text;
|
||||
import net.minecraft.text.TextContent;
|
||||
import net.minecraft.text.TranslatableTextContent;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class TextVoiceHandler {
|
||||
|
||||
public static final char START_MARKER = '\uD7F0';
|
||||
public static final char END_MARKER = '\uD7F1';
|
||||
public static final char PTT_PLAY_MARKER = '\uD7F3';
|
||||
|
||||
// leave 20 chars for /w and username
|
||||
public static final int MAX_ENCODED_LEN = 236;
|
||||
|
||||
public final PlayerAudioMixer mixer;
|
||||
public final List<short[]> pttSendBuffer = new ArrayList<>();
|
||||
public final Map<String, PlayerReceiveBuffer> receiveBuffers;
|
||||
private final Map<String, PlayerHUDState> hudStates;
|
||||
|
||||
private volatile boolean pttActive = false;
|
||||
private final Object pttLock = new Object();
|
||||
public String targetPlayer = null;
|
||||
|
||||
public final Codec2Wrapper codec2Encode = new Codec2Wrapper(Codec2Modes.CODEC2_MODE_3200);
|
||||
private final Map<String, Codec2Wrapper> playerDecoders = new ConcurrentHashMap<>();
|
||||
|
||||
public TextVoiceHandler(PlayerAudioMixer mixer, Map<String, PlayerReceiveBuffer> receiveBuffers, Map<String, PlayerHUDState> hudStates) {
|
||||
this.mixer = mixer;
|
||||
this.receiveBuffers = receiveBuffers;
|
||||
this.hudStates = hudStates;
|
||||
}
|
||||
|
||||
/** Start sending loop when PTT is pressed */
|
||||
public void startPTT() {
|
||||
pttActive = true;
|
||||
new Thread(this::pttSendLoop, "Voice-PTT-Sender").start();
|
||||
}
|
||||
|
||||
/** Stop sending loop when PTT is released */
|
||||
public void stopPTT() {
|
||||
pttActive = false;
|
||||
}
|
||||
|
||||
/** PTT sending loop, encoding entire blob at once */
|
||||
private void pttSendLoop() {
|
||||
List<byte[]> pendingFrames = new ArrayList<>();
|
||||
|
||||
while (pttActive || !pttSendBuffer.isEmpty() || !pendingFrames.isEmpty()) {
|
||||
// Grab all new PCM frames
|
||||
synchronized (pttLock) {
|
||||
if (!pttSendBuffer.isEmpty()) {
|
||||
for (short[] pcm : pttSendBuffer) {
|
||||
pendingFrames.add(codec2Encode.encodeFrame(codec2Encode.handle, pcm));
|
||||
}
|
||||
pttSendBuffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Only try to send if bundle is big enough or PTT released
|
||||
if (!pendingFrames.isEmpty()) {
|
||||
int bundleCharCount = 0;
|
||||
List<byte[]> bundle = new ArrayList<>();
|
||||
Iterator<byte[]> iter = pendingFrames.iterator();
|
||||
|
||||
while (iter.hasNext()) {
|
||||
byte[] frame = iter.next();
|
||||
String encodedFrame = SafeBMPCodec.encode(frame);
|
||||
int actualLen = encodedFrame.length();
|
||||
if (bundleCharCount + actualLen > MAX_ENCODED_LEN) {
|
||||
sendBundle(bundle);
|
||||
bundle.clear();
|
||||
bundleCharCount = 0;
|
||||
}
|
||||
bundle.add(frame);
|
||||
bundleCharCount += actualLen;
|
||||
|
||||
iter.remove();
|
||||
}
|
||||
|
||||
// Flush remaining frames only if PTT is released
|
||||
if (!bundle.isEmpty() && !pttActive) {
|
||||
sendBundle(bundle);
|
||||
} else {
|
||||
// Keep remaining frames in pendingFrames for next loop iteration
|
||||
pendingFrames.addAll(bundle);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(10);
|
||||
} catch (InterruptedException ignored) {}
|
||||
}
|
||||
|
||||
sendPTTPlaySignal();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Buffer PCM frames while holding PTT */
|
||||
public void bufferPCM(short[] pcmSamples) {
|
||||
synchronized (pttSendBuffer) {
|
||||
pttSendBuffer.add(pcmSamples);
|
||||
}
|
||||
}
|
||||
|
||||
/** Encode entire blob and send it */
|
||||
private void sendBundle(List<byte[]> codec2Frames) {
|
||||
if (codec2Frames.isEmpty()) return;
|
||||
|
||||
// Flatten frames into one blob
|
||||
int total = codec2Frames.size() * 8;
|
||||
byte[] blob = new byte[total];
|
||||
int pos = 0;
|
||||
for (byte[] frame : codec2Frames) {
|
||||
System.arraycopy(frame, 0, blob, pos, 8);
|
||||
pos += 8;
|
||||
}
|
||||
|
||||
// Encode the entire blob once
|
||||
String encoded = SafeBMPCodec.encode(blob);
|
||||
String wrapped = START_MARKER + encoded + END_MARKER;
|
||||
sendChatMessage(wrapped);
|
||||
}
|
||||
|
||||
/** Handle incoming chat messages */
|
||||
public boolean onChatMessage(Text message, @Nullable SignedMessage signedMessage,
|
||||
@Nullable GameProfile sender, MessageType.Parameters params,
|
||||
Instant timestamp) {
|
||||
if (!(message.getContent() instanceof TranslatableTextContent content)) return true;
|
||||
|
||||
String messageContent = content.getArg(1).getString();
|
||||
String playerName = content.getArg(0).getString();
|
||||
|
||||
if (!messageContent.startsWith(String.valueOf(PTT_PLAY_MARKER)) && !(messageContent.startsWith(String.valueOf(START_MARKER)) && messageContent.endsWith(String.valueOf(END_MARKER)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sender != null) {
|
||||
UUID senderUUID = sender.id();
|
||||
UUID myUUID = MinecraftClient.getInstance().getGameProfile().id();
|
||||
if (senderUUID.equals(myUUID)) return false;
|
||||
}
|
||||
|
||||
|
||||
if (!(Objects.equals(content.getKey(), "commands.message.display.incoming") || Objects.equals(content.getKey(), "chat.type.text"))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (messageContent.startsWith(String.valueOf(PTT_PLAY_MARKER))) {
|
||||
if (sender != null) {
|
||||
PlayerReceiveBuffer buffer = receiveBuffers.get(sender.name());
|
||||
if (buffer != null) {
|
||||
buffer.playOnRelease = true;
|
||||
playReceiveBuffer(sender.name(), buffer);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!messageContent.startsWith(String.valueOf(START_MARKER)) ||
|
||||
!messageContent.endsWith(String.valueOf(END_MARKER))) return true;
|
||||
|
||||
try {
|
||||
String stripped = messageContent.substring(1, messageContent.length() - 1);
|
||||
PlayerReceiveBuffer buffer = receiveBuffers.computeIfAbsent(playerName, k -> new PlayerReceiveBuffer());
|
||||
PlayerHUDState hud = hudStates.computeIfAbsent(playerName, k -> new PlayerHUDState());
|
||||
hud.lastFrameTime = System.currentTimeMillis();
|
||||
|
||||
byte[] blob = SafeBMPCodec.decode(stripped);
|
||||
|
||||
// Slice into 8-byte codec2 frames
|
||||
for (int i = 0; i < blob.length; i += 8) {
|
||||
byte[] codec2Frame = Arrays.copyOfRange(blob, i, i + 8);
|
||||
Codec2Wrapper codec2 = playerDecoders.computeIfAbsent(playerName,
|
||||
k -> new Codec2Wrapper(Codec2Modes.CODEC2_MODE_3200));
|
||||
short[] pcm = codec2.decodeFrame(codec2.handle, codec2Frame);
|
||||
synchronized (buffer.buffer) {
|
||||
buffer.buffer.add(pcm);
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.playOnRelease) playReceiveBuffer(playerName, buffer);
|
||||
} catch (Exception e) {
|
||||
MinecraftClient.getInstance().inGameHud.getChatHud()
|
||||
.addMessage(Text.translatable("gui.textvoice.errordecode", playerName, e));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Play buffered PCM frames for a player */
|
||||
private void playReceiveBuffer(String playerName, PlayerReceiveBuffer buffer) {
|
||||
PlayerHUDState hud = hudStates.computeIfAbsent(playerName, k -> new PlayerHUDState());
|
||||
int totalSamples = buffer.buffer.stream().mapToInt(f -> f.length).sum();
|
||||
|
||||
if (hud.playingTo <= System.currentTimeMillis()) {
|
||||
hud.playingTo = System.currentTimeMillis() + (totalSamples / 8);
|
||||
} else {
|
||||
hud.playingTo += totalSamples / 8;
|
||||
}
|
||||
synchronized (buffer.buffer) {
|
||||
for (short[] pcm : buffer.buffer) mixer.enqueuePlayerFrame(playerName, pcm);
|
||||
buffer.buffer.clear();
|
||||
buffer.playOnRelease = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Send PTT play control signal */
|
||||
private void sendPTTPlaySignal() {
|
||||
ClientPlayerEntity player = MinecraftClient.getInstance().player;
|
||||
if (player == null) return;
|
||||
|
||||
String control = PTT_PLAY_MARKER + (targetPlayer != null ? targetPlayer : "");
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (InterruptedException ignored) {}
|
||||
sendChatMessage(control);
|
||||
}
|
||||
|
||||
/** Send chat message, optionally to a target player */
|
||||
private void sendChatMessage(String msg) {
|
||||
if (targetPlayer == null || targetPlayer.isBlank()) return;
|
||||
|
||||
ClientPlayerEntity player = MinecraftClient.getInstance().player;
|
||||
if (player == null) return;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
double frequency = lastSentTime > 0 ? 1000.0 / (now - lastSentTime) : 0;
|
||||
lastSentTime = now;
|
||||
messagesSent++;
|
||||
|
||||
System.out.printf("[textvoice] Sending #%d: freq=%.2f Hz, target=%s, chars=%d%n",
|
||||
messagesSent, frequency, targetPlayer, msg.length());
|
||||
|
||||
if (!Objects.equals(targetPlayer, "*")) {
|
||||
String whisper = "w " + targetPlayer + " " + msg;
|
||||
player.networkHandler.sendChatCommand(whisper);
|
||||
} else {
|
||||
player.networkHandler.sendChatMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
public void setTargetPlayer(@Nullable String name) {
|
||||
this.targetPlayer = (name != null && name.length() <= 16) ? name : null;
|
||||
}
|
||||
|
||||
private long lastSentTime = 0;
|
||||
private int messagesSent = 0;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package systems.brn.textvoice.client.audio;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class AudioUtil {
|
||||
/** Simple PCM mixer: sums samples into the queue */
|
||||
public static void mixPCM(List<short[]> queue, short[] newSamples) {
|
||||
if (queue.isEmpty()) {
|
||||
queue.add(newSamples);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mix the first frame in the queue for simplicity
|
||||
short[] existing = queue.get(0);
|
||||
int len = Math.min(existing.length, newSamples.length);
|
||||
for (int i = 0; i < len; i++) {
|
||||
int mixed = existing[i] + newSamples[i];
|
||||
// Prevent overflow
|
||||
existing[i] = (short) Math.max(Math.min(mixed, Short.MAX_VALUE), Short.MIN_VALUE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package systems.brn.textvoice.client.audio;
|
||||
|
||||
import javax.sound.sampled.*;
|
||||
|
||||
public class Microphone {
|
||||
private final int sampleRate;
|
||||
private final int frameSize;
|
||||
private TargetDataLine line;
|
||||
private Mixer.Info currentMixer;
|
||||
|
||||
public Microphone(int sampleRate, int frameSize) {
|
||||
this.sampleRate = sampleRate;
|
||||
this.frameSize = frameSize;
|
||||
}
|
||||
|
||||
public void open(Mixer.Info mixerInfo) throws LineUnavailableException {
|
||||
close(); // close previous if any
|
||||
|
||||
this.currentMixer = mixerInfo;
|
||||
AudioFormat format = new AudioFormat(sampleRate, 16, 1, true, false);
|
||||
DataLine.Info info = new DataLine.Info(TargetDataLine.class, format);
|
||||
line = (TargetDataLine) AudioSystem.getLine(info);
|
||||
if (mixerInfo != null) {
|
||||
line = (TargetDataLine) AudioSystem.getMixer(mixerInfo).getLine(info);
|
||||
}
|
||||
line.open(format, frameSize * 2);
|
||||
line.start();
|
||||
}
|
||||
|
||||
public short[] readFrame() {
|
||||
if (line == null) return new short[0];
|
||||
byte[] buffer = new byte[frameSize * 2];
|
||||
int bytesRead = line.read(buffer, 0, buffer.length);
|
||||
short[] pcm = new short[bytesRead / 2];
|
||||
for (int i = 0; i < pcm.length; i++) {
|
||||
int lo = buffer[i * 2] & 0xFF;
|
||||
int hi = buffer[i * 2 + 1] << 8;
|
||||
pcm[i] = (short) (hi | lo);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (line != null) {
|
||||
line.stop();
|
||||
line.close();
|
||||
line = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void setDevice(Mixer.Info mixerInfo) throws LineUnavailableException {
|
||||
open(mixerInfo);
|
||||
}
|
||||
|
||||
public Mixer.Info getCurrentMixer() {
|
||||
return currentMixer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package systems.brn.textvoice.client.audio;
|
||||
|
||||
import javax.sound.sampled.*;
|
||||
|
||||
public class Speaker {
|
||||
private final int sampleRate;
|
||||
private SourceDataLine line;
|
||||
private Mixer.Info currentMixer;
|
||||
|
||||
public Speaker(int sampleRate) {
|
||||
this.sampleRate = sampleRate;
|
||||
}
|
||||
|
||||
public void open(Mixer.Info mixerInfo) throws LineUnavailableException {
|
||||
close(); // close previous if any
|
||||
|
||||
this.currentMixer = mixerInfo;
|
||||
AudioFormat format = new AudioFormat(sampleRate, 16, 1, true, false);
|
||||
DataLine.Info info = new DataLine.Info(SourceDataLine.class, format);
|
||||
line = (SourceDataLine) AudioSystem.getLine(info);
|
||||
if (mixerInfo != null) {
|
||||
line = (SourceDataLine) AudioSystem.getMixer(mixerInfo).getLine(info);
|
||||
}
|
||||
line.open(format);
|
||||
line.start();
|
||||
}
|
||||
|
||||
public void play(short[] pcm) {
|
||||
if (line == null) return;
|
||||
byte[] buffer = new byte[pcm.length * 2];
|
||||
for (int i = 0; i < pcm.length; i++) {
|
||||
buffer[i * 2] = (byte) (pcm[i] & 0xFF);
|
||||
buffer[i * 2 + 1] = (byte) ((pcm[i] >> 8) & 0xFF);
|
||||
}
|
||||
line.write(buffer, 0, buffer.length);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (line != null) {
|
||||
line.flush();
|
||||
line.stop();
|
||||
line.close();
|
||||
line = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void setDevice(Mixer.Info mixerInfo) throws LineUnavailableException {
|
||||
open(mixerInfo);
|
||||
}
|
||||
|
||||
public Mixer.Info getCurrentMixer() {
|
||||
return currentMixer;
|
||||
}
|
||||
}
|
||||
BIN
src/main/resources/assets/textvoice/icon.png
Normal file
BIN
src/main/resources/assets/textvoice/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
11
src/main/resources/assets/textvoice/lang/en_us.json
Normal file
11
src/main/resources/assets/textvoice/lang/en_us.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"gui.textvoice.notarget": "No target selected, TX off",
|
||||
"gui.textvoice.targettx": "TX to %s",
|
||||
"key.textvoice.ptt": "Push to talk",
|
||||
"key.category.textvoice.main" : "TextVoice",
|
||||
"gui.textvoice.errordecode" : "Voice decode error from %s: %s",
|
||||
"gui.textvoice.targetset": "Voice target set to: %s",
|
||||
"gui.textvoice.targetsetbroadcast": "Target player set to: * (broadcast)",
|
||||
"gui.textvoice.audiodevselecterror": "Input selection error: %s",
|
||||
"gui.textvoice.audiodevselected": "Selected input: %s, output: %s"
|
||||
}
|
||||
28
src/main/resources/fabric.mod.json
Normal file
28
src/main/resources/fabric.mod.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "textvoice",
|
||||
"version": "${version}",
|
||||
"name": "TextVoice",
|
||||
"description": "",
|
||||
"authors": [],
|
||||
"contact": {},
|
||||
"license": "MIT",
|
||||
"icon": "assets/textvoice/icon.png",
|
||||
"environment": "client",
|
||||
"entrypoints": {
|
||||
"fabric-datagen": [
|
||||
"systems.brn.textvoice.client.TextVoiceDataGenerator"
|
||||
],
|
||||
"client": [
|
||||
"systems.brn.textvoice.client.TextVoiceClient"
|
||||
]
|
||||
},
|
||||
"mixins": [
|
||||
"textvoice.mixins.json"
|
||||
],
|
||||
"depends": {
|
||||
"fabricloader": ">=${loader_version}",
|
||||
"fabric": "*",
|
||||
"minecraft": "${minecraft_version}"
|
||||
}
|
||||
}
|
||||
BIN
src/main/resources/native/linux/libcodec2jni.so
Executable file
BIN
src/main/resources/native/linux/libcodec2jni.so
Executable file
Binary file not shown.
BIN
src/main/resources/native/windows/codec2jni.dll
Executable file
BIN
src/main/resources/native/windows/codec2jni.dll
Executable file
Binary file not shown.
16
src/main/resources/textvoice.mixins.json
Normal file
16
src/main/resources/textvoice.mixins.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"required": true,
|
||||
"minVersion": "0.8",
|
||||
"package": "systems.brn.textvoice.mixin",
|
||||
"compatibilityLevel": "JAVA_21",
|
||||
"mixins": [
|
||||
],
|
||||
"client": [
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
},
|
||||
"overwrites": {
|
||||
"requireAnnotations": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user