diff --git a/frontends/twitch-early-access/package-lock.json b/frontends/twitch-early-access/package-lock.json
index 51dde3f938..2067db731a 100644
--- a/frontends/twitch-early-access/package-lock.json
+++ b/frontends/twitch-early-access/package-lock.json
@@ -1142,6 +1142,16 @@
"minimist": "^1.2.0"
}
},
+ "@colormap/core": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@colormap/core/-/core-0.2.0.tgz",
+ "integrity": "sha512-mFR7RHtSSDHwJq4pcXVa+MAzdRqADm5RiUkG35BfmUr9TZri/+NPfwJtDSvajzLwkOwBU0aGiSpNYX6qlOSwng=="
+ },
+ "@colormap/presets": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@colormap/presets/-/presets-0.2.0.tgz",
+ "integrity": "sha512-hzUG6oS+dJxd26k6i4pxFDWP4InWhFRcptuv4U6Mq6/Vs7TT1aPZf1rWSfCv/FgJ11k/gKsUtkFdzVGHckBLxQ=="
+ },
"@csstools/convert-colors": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@csstools/convert-colors/-/convert-colors-1.4.0.tgz",
@@ -2225,6 +2235,11 @@
"@types/node": "*"
}
},
+ "@types/howler": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.1.tgz",
+ "integrity": "sha512-1MiSldngr+eAO4lDPtjzl4Nf2GmRh8VDHIpNBIkyd25L22JExVlI6w3fjSM7+FNc1e1WZAPNq7/flkw685byfg=="
+ },
"@types/html-minifier-terser": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
@@ -7126,6 +7141,11 @@
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz",
"integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg=="
},
+ "howler": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.1.tgz",
+ "integrity": "sha512-0iIXvuBO/81CcrQ/HSSweYmbT50fT2mIc9XMFb+kxIfk2pW/iKzDbX1n3fZmDXMEIpYvyyfrB+gXwPYSDqUxIQ=="
+ },
"hpack.js": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
diff --git a/frontends/twitch-early-access/package.json b/frontends/twitch-early-access/package.json
index 921b340f44..b9cef39517 100644
--- a/frontends/twitch-early-access/package.json
+++ b/frontends/twitch-early-access/package.json
@@ -3,13 +3,17 @@
"version": "0.1.0",
"private": true,
"dependencies": {
+ "@colormap/core": "^0.2.0",
+ "@colormap/presets": "^0.2.0",
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.8.3",
+ "@types/howler": "^2.2.1",
"@types/jest": "^26.0.20",
"@types/node": "^12.20.6",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.2",
+ "howler": "^2.2.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-scripts": "4.0.3",
diff --git a/frontends/twitch-early-access/src/App.tsx b/frontends/twitch-early-access/src/App.tsx
index a4035a849c..715ca7719e 100644
--- a/frontends/twitch-early-access/src/App.tsx
+++ b/frontends/twitch-early-access/src/App.tsx
@@ -1,12 +1,40 @@
-import React from 'react';
import './App.css';
-function App() {
- return (
-
- Testing
-
- );
+import React from 'react';
+import { ComponentFrame, ShowComponent } from './components/ComponentFrame';
+import ApiConfig from './api/ApiConfig';
+
+interface Props {
+}
+
+interface State {
+ showComponent: ShowComponent,
+ apiConfig: ApiConfig,
+ text: string,
+}
+
+class App extends React.Component {
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ showComponent: ShowComponent.SPEAK,
+ apiConfig: new ApiConfig(),
+ text: "",
+ };
+ }
+
+ public updateText(text: string) {
+ }
+
+ public render() {
+ return
+ }
}
export default App;
diff --git a/frontends/twitch-early-access/src/api/ApiConfig.ts b/frontends/twitch-early-access/src/api/ApiConfig.ts
new file mode 100644
index 0000000000..735cb03669
--- /dev/null
+++ b/frontends/twitch-early-access/src/api/ApiConfig.ts
@@ -0,0 +1,70 @@
+
+enum Environment {
+ // Running in 'npm run-script start'
+ LOCALHOST_JS,
+ // Running under Rust server; 'npm run-script build'
+ LOCALHOST_RUST,
+ // Running on a deployed staging or production domain.
+ DEPLOYED,
+ // Running somewhere else
+ UNKNOWN,
+}
+
+class ApiConfig {
+
+ environment: Environment;
+
+ constructor() {
+ const url = new URL(window.location as any);
+
+ let environment;
+
+ switch (url.hostname) {
+ case "mumble.stream":
+ case "jungle.horse":
+ console.log('environment', 'deployed');
+ environment = Environment.DEPLOYED;
+ break;
+ case "localhost":
+ switch (url.port) {
+ case "8080":
+ case "7000":
+ console.log('environment', 'localhost_js');
+ environment = Environment.LOCALHOST_JS;
+ break;
+ default:
+ console.log('environment', 'localhost_rust');
+ environment = Environment.LOCALHOST_RUST;
+ }
+ break
+ default:
+ console.log('environment', 'unknown');
+ environment = Environment.UNKNOWN;
+ }
+
+ console.log('environment', environment);
+
+ this.environment = environment;
+ }
+
+ public getEndpoint(path: string) : string {
+ let baseUrl;
+ switch (this.environment) {
+ case Environment.LOCALHOST_JS:
+ baseUrl = new URL('http://localhost:12345');
+ break;
+ case Environment.LOCALHOST_RUST:
+ case Environment.DEPLOYED:
+ // Directly access via the path.
+ return path;
+ case Environment.UNKNOWN:
+ baseUrl = new URL('http://mumble.stream');
+ break;
+ }
+
+ baseUrl.pathname = path;
+ return baseUrl.toString();
+ }
+}
+
+export default ApiConfig;
diff --git a/frontends/twitch-early-access/src/components/ComponentFrame.tsx b/frontends/twitch-early-access/src/components/ComponentFrame.tsx
new file mode 100644
index 0000000000..cccb73b371
--- /dev/null
+++ b/frontends/twitch-early-access/src/components/ComponentFrame.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import ApiConfig from '../api/ApiConfig';
+import { SpeakerSpectrogramModeComponent } from './speaker_spectrogram_mode/SpeakerSpectrogramModeComponent';
+import { SpeakerModeComponent } from './speaker_mode/SpeakerModeComponent';
+
+enum ShowComponent {
+ SPEAK,
+ SPEAK_SPECTROGRAM,
+}
+
+interface Props {
+ showComponent: ShowComponent,
+ apiConfig: ApiConfig,
+ text: string,
+ updateTextCallback: (text: string) => void,
+}
+
+interface State {
+}
+
+class ComponentFrame extends React.Component {
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ };
+ }
+
+ public render() {
+
+ switch (this.props.showComponent) {
+ case ShowComponent.SPEAK_SPECTROGRAM:
+ return ;
+ case ShowComponent.SPEAK:
+ default:
+ return ;
+ }
+ }
+
+}
+
+export { ComponentFrame, ShowComponent }
diff --git a/frontends/twitch-early-access/src/components/speaker_mode/SpeakerAudioForm.tsx b/frontends/twitch-early-access/src/components/speaker_mode/SpeakerAudioForm.tsx
new file mode 100644
index 0000000000..794324abce
--- /dev/null
+++ b/frontends/twitch-early-access/src/components/speaker_mode/SpeakerAudioForm.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import Howl from 'howler';
+import ApiConfig from '../../api/ApiConfig';
+import { SpeakRequest } from '../../api/ApiDefinition'
+
+interface Props {
+ apiConfig: ApiConfig,
+ speaker?: String,
+ text: string,
+ updateTextCallback: (text: string) => void,
+}
+
+interface State {
+ howl?: Howl.Howl,
+}
+
+
+class SpeakerAudioForm extends React.Component {
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ howl: undefined,
+ };
+ }
+
+ componentDidMount() {
+ // OnMount events. An async CTOR, essentially.
+ }
+
+ handleTextChange = (ev: React.FormEvent) => {
+ const text = (ev.target as HTMLInputElement).value;
+ this.props.updateTextCallback(text);
+ }
+
+ makeRequest = (ev: React.FormEvent) => {
+ console.log("Form Submit");
+
+ if (!this.props.text) {
+ return;
+ }
+
+ let request = new SpeakRequest(this.props.text, this.props.speaker!);
+
+ const url = this.props.apiConfig.getEndpoint('/speak');
+
+ fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(request),
+ })
+ .then(res => res.blob())
+ .then(blob => {
+ //console.log(blob);
+ const url = window.URL.createObjectURL(blob);
+
+ console.log('download at the audio url:');
+ console.log(url);
+
+ const sound = new Howl.Howl({
+ src: [url],
+ //format: 'wav',
+ });
+
+ this.setState({howl: sound});
+ sound.play();
+
+ (window as any).sound = sound;
+ });
+
+ ev.preventDefault();
+ return false;
+ }
+
+ public render() {
+ return (
+
+ );
+ }
+}
+
+export default SpeakerAudioForm;
diff --git a/frontends/twitch-early-access/src/components/speaker_mode/SpeakerDropdownComponent.tsx b/frontends/twitch-early-access/src/components/speaker_mode/SpeakerDropdownComponent.tsx
new file mode 100644
index 0000000000..5d7d657e30
--- /dev/null
+++ b/frontends/twitch-early-access/src/components/speaker_mode/SpeakerDropdownComponent.tsx
@@ -0,0 +1,36 @@
+
+import React from 'react';
+import { SpeakerDetails } from './SpeakerResponse';
+
+interface Props {
+ changeCallback: (ev: React.FormEvent) => void,
+ speakers: SpeakerDetails[]
+}
+
+interface State {
+}
+
+class SpeakerDropdownComponent extends React.Component {
+
+ constructor(props: Props) {
+ super(props);
+ }
+
+ public render() {
+ return (
+
+ Select Speaker
+
+
+
+ );
+ }
+}
+
+export {SpeakerDropdownComponent};
diff --git a/frontends/twitch-early-access/src/components/speaker_mode/SpeakerModeComponent.tsx b/frontends/twitch-early-access/src/components/speaker_mode/SpeakerModeComponent.tsx
new file mode 100644
index 0000000000..8513aa54bc
--- /dev/null
+++ b/frontends/twitch-early-access/src/components/speaker_mode/SpeakerModeComponent.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { SpeakerDetails, SpeakerResponse } from './SpeakerResponse';
+import { SpeakerDropdownComponent } from './SpeakerDropdownComponent';
+import SpeakerAudioForm from './SpeakerAudioForm';
+import ApiConfig from '../../api/ApiConfig';
+
+interface Props {
+ apiConfig: ApiConfig,
+ text: string,
+ updateTextCallback: (text: string) => void,
+}
+
+interface State {
+ speaker_response?: SpeakerResponse,
+ speaker_slug?: string,
+}
+
+class SpeakerModeComponent extends React.Component {
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ speaker_response: undefined,
+ speaker_slug: undefined,
+ };
+ }
+
+ componentDidMount() {
+ this.loadSpeakers();
+ }
+
+ public loadSpeakers() {
+ const url = this.props.apiConfig.getEndpoint('/voices');
+ fetch(url)
+ .then(res => res.json())
+ .then(
+ (result) => {
+ console.log('loaded speakers', result);
+ let first_speaker = result.speakers[0].slug;
+ this.setState({
+ speaker_response: result,
+ speaker_slug: first_speaker,
+ })
+ }
+ );
+ }
+
+ changeSpeaker = (ev: React.FormEvent) => {
+ const speakerSlug = (ev.target as HTMLInputElement).value;
+ this.setState({
+ speaker_slug: speakerSlug,
+ })
+ }
+
+ public render() {
+ let speakers = this.state.speaker_response?.speakers;
+ let dropdown;
+
+ if (speakers?.length || 0 > 0) {
+ dropdown =
+ }
+
+ return (
+
+ SpeakerMode
+ {dropdown}
+
+
+ );
+ }
+}
+
+export {SpeakerModeComponent};
diff --git a/frontends/twitch-early-access/src/components/speaker_mode/SpeakerResponse.ts b/frontends/twitch-early-access/src/components/speaker_mode/SpeakerResponse.ts
new file mode 100644
index 0000000000..e6532c35de
--- /dev/null
+++ b/frontends/twitch-early-access/src/components/speaker_mode/SpeakerResponse.ts
@@ -0,0 +1,15 @@
+
+
+class SpeakerResponse {
+ speakers?: SpeakerDetails[]
+}
+
+class SpeakerDetails {
+ name?: string
+ slug?: string
+ model_pipeline?: string
+ tacotron?: string
+ melgan?: string
+}
+
+export {SpeakerResponse, SpeakerDetails};
diff --git a/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerDropdownComponent.tsx b/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerDropdownComponent.tsx
new file mode 100644
index 0000000000..5d7d657e30
--- /dev/null
+++ b/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerDropdownComponent.tsx
@@ -0,0 +1,36 @@
+
+import React from 'react';
+import { SpeakerDetails } from './SpeakerResponse';
+
+interface Props {
+ changeCallback: (ev: React.FormEvent) => void,
+ speakers: SpeakerDetails[]
+}
+
+interface State {
+}
+
+class SpeakerDropdownComponent extends React.Component {
+
+ constructor(props: Props) {
+ super(props);
+ }
+
+ public render() {
+ return (
+
+ Select Speaker
+
+
+
+ );
+ }
+}
+
+export {SpeakerDropdownComponent};
diff --git a/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerResponse.ts b/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerResponse.ts
new file mode 100644
index 0000000000..e6532c35de
--- /dev/null
+++ b/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerResponse.ts
@@ -0,0 +1,15 @@
+
+
+class SpeakerResponse {
+ speakers?: SpeakerDetails[]
+}
+
+class SpeakerDetails {
+ name?: string
+ slug?: string
+ model_pipeline?: string
+ tacotron?: string
+ melgan?: string
+}
+
+export {SpeakerResponse, SpeakerDetails};
diff --git a/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerSpectrogramAudioForm.tsx b/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerSpectrogramAudioForm.tsx
new file mode 100644
index 0000000000..08a600ee82
--- /dev/null
+++ b/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerSpectrogramAudioForm.tsx
@@ -0,0 +1,175 @@
+import React from 'react';
+import Howl from 'howler';
+import ApiConfig from '../../api/ApiConfig';
+import { createColorMap, linearScale } from "@colormap/core";
+import { magma } from "@colormap/presets";
+import { SpeakRequest } from '../../api/ApiDefinition'
+
+interface Props {
+ apiConfig: ApiConfig,
+ speaker?: String,
+ text: string,
+ updateTextCallback: (text: string) => void,
+}
+
+interface State {
+ howl?: Howl.Howl,
+ spectrogram?: Spectrogram,
+}
+
+class Spectrogram {
+ pixels: Uint8ClampedArray;
+ width: number;
+ height: number;
+
+ constructor(pixels: Uint8ClampedArray, width: number, height: number) {
+ this.pixels = pixels;
+ this.width = width;
+ this.height = height;
+ }
+}
+
+class SpeakerSpectrogramAudioForm extends React.Component {
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ howl: undefined,
+ spectrogram: undefined,
+ };
+ }
+
+ handleTextChange = (ev: React.FormEvent) => {
+ const text = (ev.target as HTMLInputElement).value;
+ this.props.updateTextCallback(text);
+ }
+
+ makeRequest = (ev: React.FormEvent) => {
+ console.log("Form Submit");
+
+ if (!this.props.text) {
+ return;
+ }
+
+ let request = new SpeakRequest(this.props.text, this.props.speaker!);
+
+ const url = this.props.apiConfig.getEndpoint('/speak_spectrogram');
+
+ fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(request),
+ })
+ .then(res => res.json())
+ .then(res => {
+ const data = `data:audio/wav;base64,${res.audio_base64}`;
+
+ const audioUrl = new URL(data);
+
+ // var image = new Image();
+ // image.src = `data:image/bmp;base64,${res.spectrogram.bytes_base64}`;
+ // console.log('image', image);
+
+ let scale = linearScale([0, 255], [0, 1]);
+ let colorMap = createColorMap(magma, scale);
+
+ // https://stackoverflow.com/a/21797381
+ function base64ToArrayBuffer(base64string: string) : Uint8ClampedArray {
+ var binary_string = window.atob(base64string);
+ var len = binary_string.length * 4;
+ let bytes = new Uint8ClampedArray(len);
+ for (let i = 0, j = 0; i < len; i++, j += 4) {
+ let val = binary_string.charCodeAt(i);
+ if (isNaN(val)) {
+ val = 0;
+ }
+ let mapped = colorMap(val);
+ let r = Math.floor(mapped[0] * 255)
+ let g = Math.floor(mapped[1] * 255)
+ let b = Math.floor(mapped[2] * 255)
+ bytes[j+0] = r;
+ bytes[j+1] = g;
+ bytes[j+2] = b;
+ bytes[j+3] = 255;
+ }
+ return bytes;
+ }
+
+ let bytes = base64ToArrayBuffer(res.spectrogram.bytes_base64);
+
+ const spectrogram = new Spectrogram(bytes, res.spectrogram.width, res.spectrogram.height);
+
+ const sound = new Howl.Howl({
+ src: [data],
+ //format: 'wav',
+ });
+
+ this.setState({
+ howl: sound,
+ spectrogram: spectrogram,
+ });
+
+ sound.play();
+
+ (window as any).sound = sound;
+ });
+
+ ev.preventDefault();
+ return false;
+
+ }
+
+ componentDidMount() {
+ this.updateCanvas();
+ }
+
+ componentDidUpdate() {
+ this.updateCanvas();
+ }
+
+ updateCanvas() {
+ const ctx = (this.refs.canvas as any).getContext('2d');
+ // let width = 300;
+ // let height = 80;
+ // ctx.clearRect(0,0, width, height);
+
+ if (this.state.spectrogram !== undefined) {
+ let width = this.state.spectrogram!.width;
+ let height = this.state.spectrogram!.height;
+
+ var image = new ImageData(this.state.spectrogram!.pixels, width, height);
+
+ createImageBitmap(image).then(renderer => {
+ ctx.drawImage(renderer, 0, 0, width * 3, height * 3)
+ });
+ }
+ }
+
+ public render() {
+ let width = 150 * 3;
+ let height = 80 * 3;
+
+ if (this.state.spectrogram !== undefined) {
+ width = this.state.spectrogram.width * 3;
+ height = this.state.spectrogram.height * 3;
+ }
+
+ let canvas =
+
+ return (
+
+ {canvas}
+
+
+
+ );
+ }
+}
+
+export default SpeakerSpectrogramAudioForm;
diff --git a/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerSpectrogramModeComponent.tsx b/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerSpectrogramModeComponent.tsx
new file mode 100644
index 0000000000..3fdfa19965
--- /dev/null
+++ b/frontends/twitch-early-access/src/components/speaker_spectrogram_mode/SpeakerSpectrogramModeComponent.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { SpeakerDetails, SpeakerResponse } from './SpeakerResponse';
+import { SpeakerDropdownComponent } from './SpeakerDropdownComponent';
+import SpeakerSpectrogramAudioForm from './SpeakerSpectrogramAudioForm';
+import ApiConfig from '../../api/ApiConfig';
+
+interface Props {
+ apiConfig: ApiConfig,
+ text: string,
+ updateTextCallback: (text: string) => void,
+}
+
+interface State {
+ speaker_response?: SpeakerResponse,
+ speaker_slug?: string,
+}
+
+class SpeakerSpectrogramModeComponent extends React.Component {
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ speaker_response: undefined,
+ speaker_slug: undefined,
+ };
+ }
+
+ componentDidMount() {
+ this.loadSpeakers();
+ }
+
+ public loadSpeakers() {
+ const url = this.props.apiConfig.getEndpoint('/voices');
+ fetch(url)
+ .then(res => res.json())
+ .then(
+ (result) => {
+ console.log('loaded speakers', result);
+ let first_speaker = result.speakers[0].slug;
+ this.setState({
+ speaker_response: result,
+ speaker_slug: first_speaker,
+ })
+ }
+ );
+ }
+
+ changeSpeaker = (ev: React.FormEvent) => {
+ const speakerSlug = (ev.target as HTMLInputElement).value;
+ this.setState({
+ speaker_slug: speakerSlug,
+ })
+ }
+
+ public render() {
+ let speakers = this.state.speaker_response?.speakers;
+ let dropdown;
+
+ if (speakers?.length || 0 > 0) {
+ dropdown =
+ }
+
+ return (
+
+ SpeakerSpectrogramMode
+ {dropdown}
+
+
+ );
+ }
+}
+
+export { SpeakerSpectrogramModeComponent };