Skip to content

Commit

Permalink
AI speech generator (#38)
Browse files Browse the repository at this point in the history
* AI Speech generation
  • Loading branch information
kubk authored May 10, 2024
1 parent e8702c4 commit 1b23da8
Show file tree
Hide file tree
Showing 19 changed files with 339 additions and 40 deletions.
8 changes: 4 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>
eruda.init();
</script>
<!-- <script src="https://cdn.jsdelivr.net/npm/eruda"></script>-->
<!-- <script>-->
<!-- eruda.init();-->
<!-- </script>-->
</html>
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"start:telegram": "npx concurrently --kill-others-on-fail \"npm run dev:frontend:start\" \"npm run dev:api:start\" \"npm run dev:tunnel\"",
"start:browser": "npx concurrently --kill-others-on-fail \"npm run dev:frontend:start\" \"npm run dev:api:start\"",
"dev:frontend:start": "vite",
"dev:api:start": "npx wrangler pages dev /functions --compatibility-date=2023-09-22 --compatibility-flags=\"nodejs_compat\"",
"dev:browser": "npx concurrently --kill-others-on-fail \"npm run dev:frontend\" \"npm run dev:api\"",
"dev:telegram": "npx concurrently --kill-others-on-fail \"npm run dev:frontend\" \"npm run dev:api\" \"npm run dev:tunnel\"",
"dev:frontend": "vite",
"dev:api": "npx wrangler pages dev /functions --compatibility-date=2023-09-22 --compatibility-flags=\"nodejs_compat\"",
"dev:tunnel": "../ngrok http --domain=causal-magpie-closing.ngrok-free.app 5173",
"build": "cp index.build.html index.html && vite build",
"typecheck": "npx tsc",
Expand Down
12 changes: 12 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ import {
AiMassGenerateResponse,
} from "../../functions/ai-mass-generate.ts";
import { UserPreviousPromptsResponse } from "../../functions/user-previous-prompts.ts";
import {
AiSpeechGenerateRequest,
AiSpeechGenerateResponse,
} from "../../functions/ai-speech-generate.ts";

export const healthRequest = () => {
return request<HealthResponse>("/health");
Expand Down Expand Up @@ -251,3 +255,11 @@ export const addCardsMultipleRequest = (body: AddCardsMultipleRequest) => {
export const userPreviousPromptsRequest = () => {
return request<UserPreviousPromptsResponse>("/user-previous-prompts");
};

export const aiSpeechGenerateRequest = (body: AiSpeechGenerateRequest) => {
return request<AiSpeechGenerateResponse, AiSpeechGenerateRequest>(
"/ai-speech-generate",
"POST",
body,
);
};
13 changes: 9 additions & 4 deletions src/lib/platform/browser/use-main-button-progress-browser.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { useMount } from "../../react/use-mount.ts";
import { autorun } from "mobx";
import { autorun, runInAction } from "mobx";
import { platform } from "../platform.ts";
import { assert } from "../../typescript/assert.ts";
import { BrowserPlatform } from "./browser-platform.ts";

export const useMainButtonProgressBrowser = (cb: () => boolean) => {
useMount(() => {
return autorun(() => {
assert(platform instanceof BrowserPlatform);
if (cb()) {
platform.isMainButtonLoading.setTrue();
runInAction(() => {
assert(platform instanceof BrowserPlatform);
platform.isMainButtonLoading.setTrue();
});
} else {
platform.isMainButtonLoading.setFalse();
runInAction(() => {
assert(platform instanceof BrowserPlatform);
platform.isMainButtonLoading.setFalse();
});
}
});
});
Expand Down
103 changes: 103 additions & 0 deletions src/screens/deck-form/card-ai-speech.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { observer } from "mobx-react-lite";
import { CardFormType } from "./store/deck-form-store.ts";
import { useBackButton } from "../../lib/platform/use-back-button.ts";
import { Screen } from "../shared/screen.tsx";
import { AudioPlayer } from "../../ui/audio-player.tsx";
import { useMainButton } from "../../lib/platform/use-main-button.ts";
import { t } from "../../translations/t.ts";
import { Button } from "../../ui/button.tsx";
import { ButtonGrid } from "../../ui/button-grid.tsx";
import { css } from "@emotion/css";
import { theme } from "../../ui/theme.tsx";
import { Flex } from "../../ui/flex.tsx";
import { Chip } from "../../ui/chip.tsx";
import { Input } from "../../ui/input.tsx";
import { useState } from "react";
import { AiSpeechGeneratorStore } from "./store/ai-speech-generator-store.ts";

type Props = {
cardForm: CardFormType;
onBack: () => void;
};

export const CardAiSpeech = observer((props: Props) => {
const { cardForm, onBack } = props;

const [store] = useState(() => new AiSpeechGeneratorStore(cardForm));
const { form } = store;

useBackButton(() => {
onBack();
});

useMainButton(t("go_back"), () => {
onBack();
});

return (
<Screen title={t("ai_speech_title")}>
{cardForm.options.value?.voice ? (
<>
<AudioPlayer src={cardForm.options.value.voice} />
<ButtonGrid>
<Button
icon={"mdi-delete"}
outline
onClick={() => {
store.onDeleteAiVoice();
}}
>
{t("delete")}
</Button>
</ButtonGrid>
</>
) : (
<>
<div
className={css({
alignSelf: "center",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: 4,
color: theme.hintColor,
width: "100%",
fontSize: 14,
})}
>
<span>{t("ai_speech_empty")}</span>
<Flex gap={8}>
{(["front", "back"] as const).map((side) => {
return (
<Chip
key={side}
fullWidth
isSelected={side === form.sourceSide.value}
onClick={() => {
form.sourceSide.onChange(side);
}}
>
{t(side)}
</Chip>
);
})}
</Flex>
<span>{t("ai_speech_type")}</span>
<Input field={form.sourceText} />
</div>

<Button
disabled={store.isLoading}
icon={store.isLoading ? "mdi-loading mdi-spin" : undefined}
outline
onClick={() => {
store.generate();
}}
>
{store.isLoading ? undefined : t("ai_speech_generate")}
</Button>
</>
)}
</Screen>
);
});
26 changes: 24 additions & 2 deletions src/screens/deck-form/card-form-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useMainButton } from "../../lib/platform/use-main-button.ts";
import { t } from "../../translations/t.ts";
import { useMainButtonProgress } from "../../lib/platform/use-main-button-progress.tsx";
import { useBackButton } from "../../lib/platform/use-back-button.ts";
import { isFormValid } from "mobx-form-lite";
import { formTouchAll, isFormValid } from "mobx-form-lite";
import { Screen } from "../shared/screen.tsx";
import { Label } from "../../ui/label.tsx";
import { HintTransparent } from "../../ui/hint-transparent.tsx";
Expand All @@ -25,6 +25,7 @@ import { ListHeader } from "../../ui/list-header.tsx";
import { formatCardType } from "./format-card-type.ts";
import { ListRightText } from "../../ui/list-right-text.tsx";
import { CardAnswerErrors } from "./card-answer-errors.tsx";
import { boolNarrow } from "../../lib/typescript/bool-narrow.ts";

type Props = {
cardFormStore: CardFormStoreInterface;
Expand Down Expand Up @@ -113,7 +114,28 @@ export const CardFormView = observer((props: Props) => {
cardFormStore.cardInnerScreen.onChange("cardType");
},
},
]}
userStore.canUseAiMassGenerate
? {
icon: (
<FilledIcon
backgroundColor={theme.icons.turquoise}
icon={"mdi-account-voice"}
/>
),
text: t("ai_speech_title"),
onClick: () => {
if (!isFormValid(cardForm)) {
formTouchAll(cardForm);
return;
}
cardFormStore.cardInnerScreen.onChange("aiSpeech");
},
right: cardForm.options.value?.voice ? (
<ListRightText text={t("yes")} />
) : undefined,
}
: undefined,
].filter(boolNarrow)}
/>
{cardFormStore.cardForm ? (
<CardAnswerErrors cardForm={cardFormStore.cardForm} />
Expand Down
10 changes: 10 additions & 0 deletions src/screens/deck-form/card-form-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CardPreview } from "./card-preview.tsx";
import { CardFormView } from "./card-form-view.tsx";
import { CardExample } from "./card-example.tsx";
import { CardType } from "./card-type.tsx";
import { CardAiSpeech } from "./card-ai-speech.tsx";

type Props = {
cardFormStore: CardFormStoreInterface;
Expand Down Expand Up @@ -48,5 +49,14 @@ export const CardFormWrapper = observer((props: Props) => {
);
}

if (cardFormStore.cardInnerScreen.value === "aiSpeech") {
return (
<CardAiSpeech
cardForm={cardForm}
onBack={() => cardFormStore.cardInnerScreen.onChange(null)}
/>
);
}

return <CardFormView cardFormStore={cardFormStore} />;
});
3 changes: 2 additions & 1 deletion src/screens/deck-form/create-mock-card-preview-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import { ListField, TextField } from "mobx-form-lite";
import { CardAnswerType } from "../../../functions/db/custom-types.ts";
import { CardAnswerFormType } from "./store/deck-form-store.ts";
import { DeckCardOptionsDbType } from "../../../functions/db/deck/decks-with-cards-schema.ts";

export const createMockCardPreviewForm = (card: {
front: string;
Expand All @@ -18,7 +19,7 @@ export const createMockCardPreviewForm = (card: {
example: new TextField<string>(card.example ?? ""),
answerType: new TextField<CardAnswerType>("remember"),
answerFormType: "new",
options: null,
options: new TextField<DeckCardOptionsDbType>(null),
answers: new ListField<CardAnswerFormType>([]),
answerId: "0",
},
Expand Down
4 changes: 2 additions & 2 deletions src/screens/deck-form/speaking-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ export const SpeakingCards = observer(() => {
value={deckFormStore.form.speakingCardsField.value}
onChange={deckFormStore.form.speakingCardsField.onChange}
options={[
{ value: "front", label: t("card_speak_side_front") },
{ value: "back", label: t("card_speak_side_back") },
{ value: "front", label: t("front") },
{ value: "back", label: t("back") },
]}
/>
) : null}
Expand Down
83 changes: 83 additions & 0 deletions src/screens/deck-form/store/ai-speech-generator-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { RequestStore } from "../../../lib/mobx-request/request-store.ts";
import { aiSpeechGenerateRequest } from "../../../api/api.ts";
import { formTouchAll, isFormValid, TextField } from "mobx-form-lite";
import { CardFormType } from "./deck-form-store.ts";
import { makeAutoObservable } from "mobx";
import { notifyError } from "../../shared/snackbar/snackbar.tsx";
import { t } from "../../../translations/t.ts";

export class AiSpeechGeneratorStore {
speechGenerateRequest = new RequestStore(aiSpeechGenerateRequest);

form = {
sourceText: new TextField("", {
validate: (value) => {
if (!value && !this.form.sourceSide.value) {
return t("ai_speech_validate");
}
},
afterChange: (newValue) => {
if (newValue && this.form.sourceSide.value !== null) {
this.form.sourceSide.onChange(null);
}
},
}),
sourceSide: new TextField<"front" | "back" | null>(null),
};

constructor(public cardForm: CardFormType) {
makeAutoObservable(
this,
{
cardForm: false,
},
{ autoBind: true },
);
}

get isLoading() {
return this.speechGenerateRequest.isLoading;
}

async generate() {
if (!isFormValid(this.form)) {
formTouchAll(this.form);
return;
}

const text = (() => {
if (this.form.sourceText.value) {
return this.form.sourceText.value;
}
if (this.form.sourceSide.value) {
return this.cardForm[this.form.sourceSide.value].value;
}
throw new Error("Unexpected state");
})();

const result = await this.speechGenerateRequest.execute({
text,
});

if (result.status === "error") {
notifyError({ e: result.error, info: "Error generating AI voice" });
return;
}
if (!result.data.data) {
notifyError(false, { message: result.data.error });
return;
}

this.cardForm.options.onChange({
...(this.cardForm.options.value || {}),
voice: result.data.data.publicUrl,
});
}

onDeleteAiVoice() {
this.cardForm.options.onChange({
...(this.cardForm.options.value || {}),
voice: null,
});
}
}
7 changes: 6 additions & 1 deletion src/screens/deck-form/store/card-form-store-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { CardFormType } from "./deck-form-store.ts";
import { TextField } from "mobx-form-lite";
import { DeckSpeakFieldEnum } from "../../../../functions/db/deck/decks-with-cards-schema.ts";

export type CardInnerScreenType = "cardPreview" | "cardType" | "example" | null;
export type CardInnerScreenType =
| "cardPreview"
| "cardType"
| "example"
| "aiSpeech"
| null;

export interface CardFormStoreInterface {
cardForm?: CardFormType | null;
Expand Down
Loading

0 comments on commit 1b23da8

Please sign in to comment.