From 642935764c04d63f24c71d309e871b217818cab3 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Sat, 23 Aug 2025 20:32:41 -0700 Subject: [PATCH] Initial commit --- .dockerignore | 28 ++ .gitignore | 59 +++ Dockerfile.frontend | 41 ++ Dockerfile.server | 50 +++ README.md | 125 ++++++ client/.babelrc | 4 + client/README.md | 7 + client/entrypoint.sh | 18 + client/favicon.xcf | Bin 0 -> 6540 bytes client/package.json | 55 +++ client/public/favicon.ico | Bin 0 -> 24838 bytes client/public/index.html | 45 ++ client/public/logo192.png | Bin 0 -> 4523 bytes client/public/logo512.png | Bin 0 -> 6247 bytes client/public/manifest.json | 25 ++ client/public/robots.txt | 3 + client/src/App.css | 229 ++++++++++ client/src/App.tsx | 218 +++++++++ client/src/Common.ts | 18 + client/src/GlobalContext.tsx | 19 + client/src/LobbyMessage.ts | 1 + client/src/MediaControl.css | 92 ++++ client/src/MediaControl.tsx | 728 +++++++++++++++++++++++++++++++ client/src/UserList.css | 137 ++++++ client/src/UserList.tsx | 148 +++++++ client/src/assets/no-network.png | Bin 0 -> 1452 bytes client/src/index.css | 43 ++ client/src/index.tsx | 13 + client/tsconfig.json | 21 + docker-compose.yml | 46 ++ server/.python-version | 1 + server/README.md | 0 server/entrypoint.sh | 32 ++ server/logger.py | 73 ++++ server/main.py | 437 +++++++++++++++++++ server/pyproject.toml | 15 + server/requirements.txt | 6 + server/uv.lock | 598 +++++++++++++++++++++++++ 38 files changed, 3335 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile.frontend create mode 100644 Dockerfile.server create mode 100644 README.md create mode 100644 client/.babelrc create mode 100644 client/README.md create mode 100644 client/entrypoint.sh create mode 100644 client/favicon.xcf create mode 100644 client/package.json create mode 100644 client/public/favicon.ico create mode 100755 client/public/index.html create mode 100644 client/public/logo192.png create mode 100644 client/public/logo512.png create mode 100644 client/public/manifest.json create mode 100644 client/public/robots.txt create mode 100755 client/src/App.css create mode 100644 client/src/App.tsx create mode 100644 client/src/Common.ts create mode 100644 client/src/GlobalContext.tsx create mode 100644 client/src/LobbyMessage.ts create mode 100644 client/src/MediaControl.css create mode 100644 client/src/MediaControl.tsx create mode 100644 client/src/UserList.css create mode 100644 client/src/UserList.tsx create mode 100755 client/src/assets/no-network.png create mode 100644 client/src/index.css create mode 100644 client/src/index.tsx create mode 100644 client/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 server/.python-version create mode 100644 server/README.md create mode 100644 server/entrypoint.sh create mode 100644 server/logger.py create mode 100644 server/main.py create mode 100644 server/pyproject.toml create mode 100644 server/requirements.txt create mode 100644 server/uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1308843 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +node_modules +build +dist +__pycache__ +*.pyc +*.pyo +*.pyd +*.log +*.swp +*.swo +.DS_Store +.vscode +.idea +*.sublime-workspace +*.sublime-project +.env +.env.* +dev-keys +*.pem +*.key +coverage +*.bak +*.tmp +*.local +package-lock.json +yarn.lock +pnpm-lock.yaml +*docker-compose.override.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a312226 --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# Node +node_modules/ +build/ +dist/ +.env +.env.* +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +*.sublime-workspace +*.sublime-project + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.env +.venv +.env/ +.venv/ + +# Certificates and keys +dev-keys/ +*.pem +*.key + +# OS +Thumbs.db +Desktop.ini + +# Docker +*.pid + +# Misc +*.bak +*.tmp + +# Test coverage +coverage/ + +# Local config +*.local + +# Ignore lock files +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Ignore docker-compose override +*docker-compose.override.yml diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..9585fa6 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,41 @@ +FROM ubuntu:noble + +RUN apt-get -q update \ + && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ + ca-certificates curl gnupg \ + curl \ + nano \ + sqlite3 \ + psmisc \ + wget \ + jq \ + less \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} +RUN mkdir -p /etc/apt/keyrings +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg + +# https://nodejs.org/en/about/previous-releases +ENV NODE_MAJOR=24 +RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list + +RUN apt-get -q update \ + && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ + nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} + +COPY /client /client +WORKDIR /client + +# Set environment variable for production mode (default: development) +ENV PRODUCTION=false + +# Disable HTTPS by default for npm development server +ENV HTTPS=false + +COPY ./client/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..ae0838c --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,50 @@ +FROM ubuntu:oracular + +# Install some utilities frequently used +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + curl \ + gpg \ + iputils-ping \ + jq \ + nano \ + rsync \ + wget \ + python3 \ + python3-pip \ + # python3-venv \ + # python3-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} + +# Install latest Python3 +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log} + + +# Install uv using the official Astral script +RUN curl -Ls https://astral.sh/uv/install.sh | bash +ENV PATH=/root/.local/bin:$PATH + +WORKDIR /server + +# Copy code + + +# Copy code and entrypoint +COPY ./server /server +COPY ./client/build /client/build +COPY ./server/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Set environment variable for production mode (default: development) +ENV PRODUCTION=false + +# the cache and target directories are on different filesystems, hardlinking may not be supported. +ENV UV_LINK_MODE=copy + +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b43dad1 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# AI Voicebot + +AI Voicebot is an agentic AI agent that communicates via ICE and TURN running on a coturn server. + +coturn provides ICE and related specs: + +* RFC 5245 - ICE +* RFC 5768 – ICE–SIP +* RFC 6336 – ICE–IANA Registry +* RFC 6544 – ICE–TCP +* RFC 5928 - TURN Resolution Mechanism + +## To use + +Set the environment variable COTURN_SERVER to point to the URL running the +coturn server by modifying the .env file: + +```.env +COTURN_SERVER="turns:ketrenos.com:5349" +``` + +You then launch the application, providing + +## Architecture + +The system is broken into two major components: client and server + +### client + +The frontend client is written using React, exposed via a static build of the +client through the server's static file endpoint. + +Implementation of the client is in the `client` subdirectory. + +Provides a Web UI for starting a human chat session. A lobby is created based on the URL, and any user with that URL can join that lobby. + +The client uses RTCPeerConnection, RTCSessionDescription, RTCIceCandidate, MediaStream, navigator.getUserMedia, navigator.mediaDevices, and associated APIs for creating audio (via audio tag) and video (via video tag) media instantiations in the Web UI client. + +The client also exposes the ability to add new AI "users" to the lobby. When creating a user, you can provide a brief description of the user. The server +will use that description to generate an AI person, including profile picture, voice signature used for text-to-speech, etc. + +### server + +The backend server is written in Python and the OpenAI Agentic AI SDK, connecting to an OPENAI compatible server running at OPENAI_BASE_URL. + +Implementation of the client is in the `server` subdirectory. + +The model used by the server for LLM communication is set via OPENAI_MODEL. For example: + +```.env +OPENAI_BASE_URL=http://192.168.1.198:8000/v3 +OPENAI_MODEL=Qwen/Qwen3-8B +OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +``` + + +If you want to use OpenAI instead of a self hosted service, do not set OPENAI_BASE_URL and set the OPENAI_API_KEY accordingly. + +The server provides the AI chatbot and hosts the static files for the client frontend. + + +### Speech-to-Text and Text-to-Speech Configuration + +The server supports pluggable speech-to-text (STT) and text-to-speech (TTS) backends. To configure these, set the following environment variables in your `.env` file: + +``` +STT_MODEL=your-speech-to-text-model +TTS_MODEL=your-text-to-speech-model +``` + +These models are used to transcribe incoming audio and synthesize AI responses, respectively. (See future roadmap for planned model support.) + + +The server communicates with the coturn server in the same manner as the client, only via Python instead. + + +The server exposes an http endpoint via FastAPI. This endpoint exposes the following capabilities: + +1. Lobby creation +2. User management within lobby +3. AI agent creation for a lobby +4. Connection details for the voice system to attach / detach to audio coturn streams as users join / leave. + + +Once an AI agent is added to a lobby, it joins the audio stream(s) for that lobby. + + +Audio input is then passed to the speech-to-text processor to provide a stream of text with time markers. + + +That text is then passed to the language processing layer of the AI agent, which passes it to the LLM for a response. + + +The response is then passed through the text-to-speech processor, with the output stream being routed back to coturn server for dispatch to the human UI viewers. + +## Lobby Features + +- **Player Management:** Players can join/leave lobbies, and their status is tracked in real time. +- **AI and Human Users:** Both AI and human users can participate in lobbies. AI users are generated with custom profiles and voices. + +### Media and Peer Connection Handling + +- **WebRTC Integration:** The client uses WebRTC APIs (RTCPeerConnection, RTCSessionDescription, RTCIceCandidate, MediaStream, etc.) to manage real-time audio/video streams between users and AI agents. +- **Dynamic Peer Management:** Peers are dynamically added/removed as users join or leave lobbies. The system handles ICE candidate negotiation, connection state changes, and media stream routing. +- **Audio/Video UI:** Audio and video streams are rendered in the browser using standard HTML media elements. + +### Extensibility and Planned Enhancements + +- **Pluggable STT/TTS Backends:** Support for additional speech-to-text and text-to-speech providers is planned. +- **Custom AI Agent Personalities:** Future versions will allow more detailed customization of AI agent behavior, voice, and appearance. +- **Improved Moderation and Controls:** Features for lobby moderation, user muting, and reporting are under consideration. +- **Mobile and Accessibility Improvements:** Enhanced support for mobile devices and accessibility features is on the roadmap. + +--- + +## Roadmap + +- [ ] Add support for multiple STT/TTS providers +- [ ] Expand game logic and add new game types +- [ ] Improve AI agent customization options +- [ ] Add lobby moderation and user controls +- [ ] Enhance mobile and accessibility support + +Contributions and feature requests are welcome! + diff --git a/client/.babelrc b/client/.babelrc new file mode 100644 index 0000000..6e867f9 --- /dev/null +++ b/client/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": [ "@babel/env", "@babel/preset-react" ], + "plugins": [ "@babel/plugin-proposal-class-properties" ] +} diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..16fe669 --- /dev/null +++ b/client/README.md @@ -0,0 +1,7 @@ +To deploy: + +```bash +export PUBLIC_URL=/ai-voicebot +npm run build +rsync --delete -avrpl build/ webserver:/var/www/ketrenos.com/ai-voicebot/ +``` diff --git a/client/entrypoint.sh b/client/entrypoint.sh new file mode 100644 index 0000000..40023cc --- /dev/null +++ b/client/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Launch server in production or development mode +if [ "$PRODUCTION" = "true" ]; then + export REACT_APP_AI_VOICECHAT_BUILD="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + npm install + npm run build +else + export REACT_APP_AI_VOICECHAT_BUILD="Development" + npm install + if [ -f "${SSL_CERTFILE}" ] && [ -f "${SSL_KEYFILE}" ]; then + export HTTPS=true + else + echo "SSL files not found, starting frontend WS without SSL." + export HTTPS=false + fi + npm start +fi \ No newline at end of file diff --git a/client/favicon.xcf b/client/favicon.xcf new file mode 100644 index 0000000000000000000000000000000000000000..d4b65f741c12a19ebed076bdcf4539e9cf76f4f3 GIT binary patch literal 6540 zcmeHLU1(g#6+YTO?XI+cTJK6ijk>;W6j`=p$%?87%d%@yLRwPjLqMbyVym*UIF>9+ zNiAp_w^bhmp%pj{DaL|26xV7J^~EN2Ok;>d5iFQCB5a8;P8$8RaU2&#yVCBxbNbEP zdsnw@=tB#AFq-AeoH;Xd{^rb?xjT?Pc`9|L?|AC@*0wf6Ic$N%Uk1bgWo4-10RNFm zA_*uv03JXEAZg)pUIMTVhz@{tbNb}bf&S)!)9LSkL~{JkjU%ID&-SO%gQNYYNBa9l z(?f$JyMv9#2l|Gd9Xg$AR88~gfn!Hgt?Jjd8^gywXSgk;escKC$f=`!>A`_i+x|yd zTDzPDqhtSZ)EaDlZZJKX>gyjIO`q(Sx=FS}$B&QnkEU8B69;ah=8E)S-!uJ~EDdy& zvT?KHIcK*1OJ*DXs$-IkMuA9>4)Q{0k0TDkoN(#7@b&lv;Bb;G7}(xsKNOEZ$1R)IFeWdPPZTqrIx=a&LpxI4ldXR`q= z-W}N(UkI{(_vs%kRG|6Bw|`e5sq5c=GpMHJNB$V#&9z@KfBN%+8*EH=F0d}sY*Cc7 zvHM@Bn>Knbt_ZPmvBygyJYCLWI_v^=6YjhjCYr8bW14zGEP5`Yy10UBMZj$UER#J& z#|)!K&Mr<9$<^Yf;$w33Zi#WrovpFJaUAwb`u%ha{v}^{tu!IcEZJ`z1MiHYnw9Mv z^t^wt>7ue;#y-wLy~HBG7;TI_S{!%+q1?&F^1i>i*!a!2J`MB660I;_)jo*urbMr` z+=_Am*qeKA#kjacFLvCnlI`R4`zkr`;dy!^X3o7zZ$&KzGxTl*hfMMkuY4JNjeD2= z4jCJ-W$Ga_0oifr5VQ1=dYKwZT}6H6w%-3=fBPucK0cpEVw@juuOOpo)+lGgm~HZt z7%Nn;D2(}t&qbKed5Fhi%wst1O$CHRp^0cV&iX918*!{@A5X9tpGP&HM||b?0no`S z3dW3aIZrp*<*MQ=k>nx@O)iTs_$xWSl4QR_p9-g<^4P2EDuvdO6WS&He#!~*3U`!% z^<+?_6lw`pP6)gM+%72~5>Nu~n=O%`WH~Oba@>uS^dTqsaD{ns9)mcSeytk)TAa-~ zb^~161GwS>Y}~#6o`;Ra>o@&4zd>L$uNf7tc4)dc4;!u-*pN0*bl zA@RR_<;YSsZvwr2=+TdAxUfja_CB#%%e;sfs=ZMw$6kKJO|RFO3*K#|*QzZ(w-3_w zq%Gjqp3r*;AXD^0sQcppbb)Rlcv!zm&m(BJl+}&5uYV%n0H!4#SmlFtepnfVwO&|l zS}*-zdPF3*y|e8d1p0p$I|2GE+It~$N7wIUF7!rYWHe5#AwJip8bf46Ch~afCQ z5)BK;c?%85uAR$q7R#N;b`8r&lgkYr5|M>!j7HqV6F4OkI3Js>~f!cXXUrfY4P>(bWvhHG1VbeJv`v6sL$JSCMZ-=2ddsCCSji z(gu228bVh~>!8CH*j2JI_Rr?4q;(isM}i|eKaB2um7TrPpsf;de#jjLU4oUJBS(ST z4ql zFnS2umy8Y*dE`$L@e^N_BbajIETa2W!(^go#kCOP{=8udky7730>UeXsRn1E*vC)f zDE`tWK=-v%Ak5gR!@S%X5az4Z)7G>Ho>)&qoOV0m@DIEu9W0)>=kyvp{I|zIeXbxM z<@OF?Gd{rf^Q%>k9m0(_2T@2LTU!}q{smTlJr`BadQ*N7N+)r1!n6IGCq0QkI8a}}KFi{3ghA4jkwovy$7TuGmCg)JiEumUkN42gW zWY%9OBh={Xil#=@JhcLAjc&0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/client/public/favicon.ico b/client/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9c9941f55062b10a6f4bb3d43a3384ba21260e01 GIT binary patch literal 24838 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 + + + + + + + + + + + + + + AI Voice Chat + + + +
+ + + diff --git a/client/public/logo192.png b/client/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..dec0696086aeacb5d6ea51d2c3918230f5b8f8d3 GIT binary patch literal 4523 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 literal 0 HcmV?d00001 diff --git a/client/public/logo512.png b/client/public/logo512.png new file mode 100644 index 0000000000000000000000000000000000000000..941a26744b0bd133f6e2dca1d844e0d02158037f GIT binary patch literal 6247 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 R;jbQ`aqgmOzKUh={{VWZjsE}u literal 0 HcmV?d00001 diff --git a/client/public/manifest.json b/client/public/manifest.json new file mode 100644 index 0000000..3d9c511 --- /dev/null +++ b/client/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "AI Voicebot", + "name": "AI Voicebot", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/client/public/robots.txt b/client/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/client/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/client/src/App.css b/client/src/App.css new file mode 100755 index 0000000..de25b97 --- /dev/null +++ b/client/src/App.css @@ -0,0 +1,229 @@ +body { + font-family: 'Droid Sans', 'Arial Narrow', Arial, sans-serif; + overflow: hidden; +} + +#root { + width: 100vw; +/* height: 100vh; breaks on mobile -- not needed */ +} + +.Table { + display: flex; + position: absolute; + top: 0; + left: 0; + width: 100%; + bottom: 0; + flex-direction: row; + /* background-image: url("./assets/tabletop.png"); */ +} + +.Table .Dialogs { + z-index: 10000; + display: flex; + justify-content: space-around; + align-items: center; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} + +.Table .Dialogs .Dialog { + display: flex; + position: absolute; + flex-shrink: 1; + flex-direction: column; + padding: 0.25rem; + left: 0; + right: 0; + top: 0; + bottom: 0; + justify-content: space-around; + align-items: center; + z-index: 60000; +} + +.Table .Dialogs .Dialog > div { + display: flex; + padding: 1rem; + flex-direction: column; +} + +.Table .Dialogs .Dialog > div > div:first-child { + padding: 1rem; +} + +.Table .Dialogs .TurnNoticeDialog { + background-color: #7a680060; +} + +.Table .Dialogs .ErrorDialog { + background-color: #40000060; +} + +.Table .Dialogs .WarningDialog { + background-color: #00000060; +} + +.Table .Game { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.Table .Board { + display: flex; + position: relative; + flex-grow: 1; + z-index: 500; +} + +.Table .PlayersStatus { + z-index: 500; /* Under Hand */ +} + +.Table .PlayersStatus.ActivePlayer { + z-index: 1500; /* On top of Hand */ +} + +.Table .Hand { + display: flex; + position: relative; + height: 11rem; + z-index: 10000; +} + +.Table .Sidebar { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 25rem; + max-width: 25rem; + overflow: hidden; + z-index: 5000; +} + +.Table .Sidebar .Chat { + display: flex; + position: relative; + flex-grow: 1; +} + +.Table .Trade { + display: flex; + position: relative; + z-index: 25000; + align-self: center; +} + +.Table .Dialogs { + position: absolute; + display: flex; + top: 0; + bottom: 0; + right: 0; + left: 0; + justify-content: space-around; + align-items: center; + z-index: 20000; + pointer-events: none; +} + +.Table .Dialogs > * { + pointer-events: all; +} + +.Table .ViewCard { + display: flex; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.Table .Winner { + display: flex; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + + +.Table .HouseRules { + display: flex; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.Table .ChooseCard { + display: flex; + position: relative; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.Table button { + margin: 0.25rem; + background-color: white; + border: 1px solid black; /* why !important */ +} + +.Table .MuiButton-text { + padding: 0.25rem 0.55rem; +} + +.Table button:disabled { + opacity: 0.5; + border: 1px solid #ccc; /* why !important */ +} + +.Table .ActivitiesBox { + display: flex; + flex-direction: column; + position: absolute; + left: 1em; + top: 1em; +} + +.Table .DiceRoll { + display: flex; + flex-direction: column; + position: relative; + /* + left: 1rem; + top: 5rem;*/ + flex-wrap: wrap; + justify-content: left; + align-items: left; + z-index: 1000; +} + +.Table .DiceRoll div:not(:last-child) { + border: 1px solid black; + background-color: white; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; +} +.Table .DiceRoll div:last-child { + display: flex; + flex-direction: row; +} + +.Table .DiceRoll .Dice { + margin: 0.25rem; + width: 2.75rem; + height: 2.75rem; + border-radius: 0.5rem; +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..c1be5a1 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,218 @@ +import React, { useState, useEffect, KeyboardEvent, useRef } from "react"; +import { Input, Paper, Typography } from "@mui/material"; + +import { GlobalContext, GlobalContextType } from "./GlobalContext"; +import { UserList } from "./UserList"; +import "./App.css"; +import { base } from "./Common"; +import { Box, Button } from "@mui/material"; +import { BrowserRouter as Router, Route, Routes, useParams } from "react-router-dom"; + +console.log(`AI Voice Chat Build: ${process.env.REACT_APP_AI_VOICECHAT_BUILD}`); + +type LobbyProps = { + lobbyId: string; + sessionId: string; +}; +const Lobby: React.FC = (props: LobbyProps) => { + const { lobbyId, sessionId } = props; + const [editName, setEditName] = useState(""); + const [name, setName] = useState(""); + const [ws, setWs] = useState(undefined); + const [error, setError] = useState(null); + const [global, setGlobal] = useState({ + connected: false, + ws: undefined, + name: "", + chat: [], + }); + + useEffect(() => { + console.log(global); + }, [global]); + + const onWsMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data); + switch (data.type) { + case "error": + setError(data.error); + break; + default: + break; + } + }; + const refWsMessage = useRef(onWsMessage); + + useEffect(() => { + refWsMessage.current = onWsMessage; + }); + + useEffect(() => { + if (!ws) { + return; + } + const cbMessage = (e: MessageEvent) => refWsMessage.current(e); + ws.addEventListener("message", cbMessage); + return () => { + ws.removeEventListener("message", cbMessage); + }; + }, [ws, refWsMessage]); + + // Setup websocket connection on mount (only once) + useEffect(() => { + if (!lobbyId) { + console.log("No lobby ID"); + return; + } + let loc = window.location, + new_uri; + if (loc.protocol === "https:") { + new_uri = "wss"; + } else { + new_uri = "ws"; + } + new_uri = `${new_uri}://${loc.host}${base}/ws/lobby/${lobbyId}`; + const socket = new WebSocket(new_uri); + socket.onopen = () => { + console.log("WebSocket connected"); + setGlobal((g: GlobalContextType) => ({ ...g, connected: true })); + if (name) { + socket.send(JSON.stringify({ type: "set_name", name })); + } + }; + setWs(socket); + setGlobal((g: GlobalContextType) => ({ ...g, ws: socket })); + return () => { + setGlobal((g: GlobalContextType) => ({ ...g, connected: false, ws: undefined })); + if (socket.readyState !== 0) { + socket.close(); + } + }; + // Only run once on mount + // eslint-disable-next-line + }, []); + + // Update global context and send set_name when name changes + useEffect(() => { + if (!ws || !global.connected || global.name === name) { + return; + } + setGlobal((g: GlobalContextType) => ({ ...g, name })); + console.log("Sending set_name", name); + ws.send(JSON.stringify({ type: "set_name", name })); + }, [name, ws, global]); + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Enter") { + event.preventDefault(); + if (!editName.trim()) { + return; + } + setName(editName.trim()); + setEditName(""); + } + }; + + return ( + + {!global.connected ? ( +

Connecting to server...

+ ) : ( + + {global.name && } + + {!global.name && ( + + Enter your name to join: + + { + setEditName(e.target.value); + }} + onKeyDown={handleKeyDown} + placeholder="Your name" + /> + + + + )} + + )} + {error && ( + + {error} + + )} +
+ ); +}; + +const App = () => { + const [sessionId, setSessionId] = useState(undefined); + const [error, setError] = useState(null); + const { lobbyId = "default" } = useParams<{ lobbyId: string }>(); + + useEffect(() => { + console.log(`App - sessionId`, sessionId); + }, [sessionId]); + + useEffect(() => { + if (sessionId) { + return; + } + fetch(`${base}/api/lobby`, { + method: "GET", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => { + if (res.status >= 400) { + const error = `Unable to connect to AI Voice Chat server! Try refreshing your browser in a few seconds.`; + console.error(error); + setError(error); + } + return res.json(); + }) + .then((data) => { + setSessionId(data.session); + }) + .catch((error) => {}); + }, [sessionId, setSessionId]); + + return ( + + {!sessionId &&

Connecting to server...

} + {sessionId && ( + + + } path={`${base}/:lobbyId`} /> + } path={`${base}`} /> + + + )} + {error && ( + + {error} + + )} +
+ ); +}; + +export default App; diff --git a/client/src/Common.ts b/client/src/Common.ts new file mode 100644 index 0000000..1ce4345 --- /dev/null +++ b/client/src/Common.ts @@ -0,0 +1,18 @@ +function debounce void>(fn: T, ms: number) { + let timer: ReturnType; + return function(this: any, ...args: Parameters) { + clearTimeout(timer); + timer = setTimeout(() => { + timer = null as any; + fn.apply(this, args); + }, ms); + }; +} + +const base = process.env.PUBLIC_URL || ""; + +const assetsPath = `${base}/assets`; +const gamesPath = `${base}`; + +export { base, debounce, assetsPath, gamesPath }; +export {}; diff --git a/client/src/GlobalContext.tsx b/client/src/GlobalContext.tsx new file mode 100644 index 0000000..cbcb273 --- /dev/null +++ b/client/src/GlobalContext.tsx @@ -0,0 +1,19 @@ +import { createContext } from "react"; + +interface GlobalContextType { + connected: boolean; + ws?: WebSocket; + name?: string; + chat?: any[]; + [key: string]: any; +} + +const GlobalContext = createContext({ + connected: false, + ws: undefined, + name: "", + chat: [] +}); + +export { GlobalContext }; +export type { GlobalContextType }; diff --git a/client/src/LobbyMessage.ts b/client/src/LobbyMessage.ts new file mode 100644 index 0000000..5d18778 --- /dev/null +++ b/client/src/LobbyMessage.ts @@ -0,0 +1 @@ +type LobbyMessage = {} \ No newline at end of file diff --git a/client/src/MediaControl.css b/client/src/MediaControl.css new file mode 100644 index 0000000..8ee364a --- /dev/null +++ b/client/src/MediaControl.css @@ -0,0 +1,92 @@ +.MediaControlSpacer { + display: flex; + width: 5rem; + min-width: 5rem; + height: 3.75rem; + min-height: 3.75rem; + background-color: #444; + border-radius: 0.25rem; +} + +.MediaControlSpacer.Medium { + width: 11.5em; + height: 8.625em; + min-width: 11.5em; + min-height: 8.625em; +} + + +.MediaControl { + display: flex; + position: fixed; + flex-direction: row; + justify-content: flex-end; + align-items: center; + width: 5rem; + height: 3.75rem; + min-width: 5rem; + min-height: 3.75rem; + z-index: 50000; +} + +.MediaControl .Video { + position: relative; + width: 100%; + height: 100%; + background-color: #444; + border-radius: 0.25rem; + border: 1px solid black; +} + +.MediaControl.Medium { + width: 11.5em; + height: 8.625em; + min-width: 11.5em; + min-height: 8.625em; +} + +.MediaControl > div { + display: flex; + position: absolute; + top: 0; + left: 0; + display: flex; + flex-direction: column; + align-items: center; + margin-right: 0.25rem; +} + +.MediaControl .Controls { + display: flex; + position: absolute; + left: 0.5em; + bottom: 0.5em; + justify-content: flex-end; + z-index: 1; +} + +.MediaControl.Small .Controls { + left: 0; + bottom: unset; + justify-content: center; +} + +.MediaControl .Controls > div { + display: flex; + border-radius: 0.25em; + cursor: pointer; + padding: 0.25em; +} + +.MediaControl .Controls > div:hover { + background-color: #d0d0d0; +} + +.moveable-control-box { + border: none; + --moveable-color: unset !important; +} + +.moveable-control-box .moveable-direction { + border: none !important; +} \ No newline at end of file diff --git a/client/src/MediaControl.tsx b/client/src/MediaControl.tsx new file mode 100644 index 0000000..8044126 --- /dev/null +++ b/client/src/MediaControl.tsx @@ -0,0 +1,728 @@ +import React, { useState, useEffect, useRef, useCallback, useContext } from "react"; +import Moveable from "react-moveable"; +import "./MediaControl.css"; +import VolumeOff from "@mui/icons-material/VolumeOff"; +import VolumeUp from "@mui/icons-material/VolumeUp"; +import MicOff from "@mui/icons-material/MicOff"; +import Mic from "@mui/icons-material/Mic"; +import VideocamOff from "@mui/icons-material/VideocamOff"; +import Videocam from "@mui/icons-material/Videocam"; +import { GlobalContext } from "./GlobalContext"; +import Box from "@mui/material/Box"; + +const debug = true; + +// Types for peer and track context +interface Peer { + name: string; + hasAudio: boolean; + hasVideo: boolean; + attributes: Record; + muted: boolean; + videoOn: boolean; + local: boolean; + dead: boolean; + connection?: RTCPeerConnection; +} + +interface TrackContext { + media: MediaStream; + audio: boolean; + video: boolean; +} + +interface AddPeerConfig { + peer_id: string; + hasAudio: boolean; + hasVideo: boolean; + should_create_offer?: boolean; +} + +interface SessionDescriptionData { + peer_id: string; + session_description: RTCSessionDescriptionInit; +} + +interface IceCandidateData { + peer_id: string; + candidate: RTCIceCandidateInit; +} + +interface RemovePeerData { + peer_id: string; +} + +interface VideoProps extends React.VideoHTMLAttributes { + srcObject: MediaProvider; + local?: boolean; +} + +const Video: React.FC = ({ srcObject, local, ...props }) => { + const refVideo = useRef(null); + useEffect(() => { + if (!refVideo.current) { + return; + } + const ref = refVideo.current; + if (debug) console.log("media-control - video