Add translation feature and language selection

- Implemented translation page for audio files
- Added language selection dropdown with comprehensive language codes
- Updated UI components to support language selection
- Migrated from iframe to custom UI API
- Added demo image to README
- Updated package dependencies
This commit is contained in:
Huakun Shen 2025-02-06 22:05:43 -05:00
parent 51fcb8285e
commit 5e254903ce
No known key found for this signature in database
13 changed files with 309 additions and 19 deletions

View File

@ -2,3 +2,5 @@
This extension helps you to convert an audio file to text.
It uses OpenAI's Whisper model API to convert the audio to text.
![Image](https://i.imgur.com/wWIM2nA.png)

View File

@ -4,7 +4,7 @@ const apiKey = Deno.env.get('OPENAI_API_KEY');
const openai = new OpenAI({ apiKey });
// expect a audio.m4a file in the current directory
const fileData = await Deno.readFile('./audio.m4a');
const fileData = await Deno.readFile('./chinese.m4a');
// Convert to a File (filename is required)
const file = new File([fileData], 'audio.m4a', { type: 'audio/m4a' });
@ -12,8 +12,12 @@ const file = new File([fileData], 'audio.m4a', { type: 'audio/m4a' });
const transcription = await openai.audio.transcriptions.create({
file: file,
model: 'whisper-1',
language: 'en' // this is optional but helps the model
language: 'zh' // this is optional but helps the model
});
// const transcription = await openai.audio.transcriptions.create({
// file: file,
// model: 'whisper-1',
// language: 'en' // this is optional but helps the model
// });
console.log(transcription);
Deno.writeTextFileSync('transcription.txt', transcription.text);

22
deno-src/translate.ts Normal file
View File

@ -0,0 +1,22 @@
import OpenAI from 'openai';
const apiKey = Deno.env.get('OPENAI_API_KEY');
const openai = new OpenAI({ apiKey });
// expect a audio.m4a file in the current directory
const fileData = await Deno.readFile('./chinese.m4a');
// Convert to a File (filename is required)
const file = new File([fileData], 'audio.m4a', { type: 'audio/m4a' });
const transcription = await openai.audio.translations.create({
file: file,
model: 'whisper-1'
});
// const transcription = await openai.audio.transcriptions.create({
// file: file,
// model: 'whisper-1',
// language: 'en' // this is optional but helps the model
// });
console.log(transcription);

View File

@ -12,7 +12,9 @@
"type": "iconify",
"value": "arcticons:live-transcribe"
},
"demoImages": [],
"demoImages": [
"https://i.imgur.com/wWIM2nA.png"
],
"permissions": [
"event:drag-drop",
"clipboard:write-text",
@ -75,7 +77,7 @@
"format": "prettier --write ."
},
"dependencies": {
"@kksh/api": "0.1.0",
"@kksh/api": "0.1.1",
"@kksh/svelte5": "0.1.15",
"clsx": "^2.1.1",
"lucide-svelte": "^0.469.0",

10
pnpm-lock.yaml generated
View File

@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@kksh/api':
specifier: 0.1.0
version: 0.1.0(axios@1.7.9)(svelte@5.19.9)(typescript@5.7.3)
specifier: 0.1.1
version: 0.1.1(axios@1.7.9)(svelte@5.19.9)(typescript@5.7.3)
'@kksh/svelte5':
specifier: 0.1.15
version: 0.1.15(lucide-svelte@0.469.0(svelte@5.19.9))(svelte-sonner@0.3.28(svelte@5.19.9))(svelte@5.19.9)(sveltekit-superforms@2.23.1(@sveltejs/kit@2.17.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.9)(vite@6.1.0(@types/node@22.13.1)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.19.9)(vite@6.1.0(@types/node@22.13.1)(jiti@1.21.7)(yaml@2.7.0)))(@types/json-schema@7.0.15)(svelte@5.19.9)(typescript@5.7.3))(typescript@5.7.3)
@ -458,8 +458,8 @@ packages:
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@kksh/api@0.1.0':
resolution: {integrity: sha512-lJyhfRUpj1Tx42aejaXCaVegBeSNDgIlDI9ZnGvnHykwJHUjvHk/2BrFJghtQAaraxmqD4XN+0BcQbpu6RfAvg==}
'@kksh/api@0.1.1':
resolution: {integrity: sha512-/9JLyOSAK4/dZ74LKzbqJ8LRT0otwtecS+I/k1Bs25m+DfYX8ONaWUwuwc5yufus6vqNbfAF/PHOCEs0aAE39A==}
'@kksh/svelte5@0.1.15':
resolution: {integrity: sha512-Cr/gSWsnRtQIQLpQAkGBODujWn5g4LlhDp865skRV95tkrOuAwbbWGjG5+oWx1fK+fiDu+rhe2UCqw61SW2B/Q==}
@ -2908,7 +2908,7 @@ snapshots:
'@jsdevtools/ono@7.1.3': {}
'@kksh/api@0.1.0(axios@1.7.9)(svelte@5.19.9)(typescript@5.7.3)':
'@kksh/api@0.1.1(axios@1.7.9)(svelte@5.19.9)(typescript@5.7.3)':
dependencies:
'@huakunshen/jsr-client': 0.1.5(axios@1.7.9)(typescript@5.7.3)
'@octokit/rest': 21.1.0

View File

@ -1,3 +1,4 @@
export interface API {
transcribe(filepath: string, language: string): Promise<string>;
translate(filepath: string): Promise<string>;
}

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { ThemeCustomizerButton, type ThemeConfig, updateTheme } from '@kksh/svelte5';
import { ui } from '@kksh/api/ui/iframe';
import { ui } from '@kksh/api/ui/custom';
import { onMount } from 'svelte';
let config: ThemeConfig = {

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { ui } from '@kksh/api/ui/iframe';
import { ui } from '@kksh/api/ui/custom';
import { Sidebar } from '@kksh/svelte5';
import { DatabaseIcon, InfoIcon } from 'lucide-svelte';
import { DatabaseIcon, InfoIcon, LanguagesIcon } from 'lucide-svelte';
import { onMount } from 'svelte';
onMount(() => {
@ -14,6 +14,11 @@
url: '/',
icon: DatabaseIcon
},
{
title: 'Translate',
url: '/translate',
icon: LanguagesIcon
},
{
title: 'Preferences',
url: '/preferences',

59
src/lib/constants.ts Normal file
View File

@ -0,0 +1,59 @@
export const languageCodes = [
{ label: 'Afrikaans', value: 'af' },
{ label: 'Arabic', value: 'ar' },
{ label: 'Armenian', value: 'hy' },
{ label: 'Azerbaijani', value: 'az' },
{ label: 'Belarusian', value: 'be' },
{ label: 'Bosnian', value: 'bs' },
{ label: 'Bulgarian', value: 'bg' },
{ label: 'Catalan', value: 'ca' },
{ label: 'Chinese', value: 'zh' },
{ label: 'Croatian', value: 'hr' },
{ label: 'Czech', value: 'cs' },
{ label: 'Danish', value: 'da' },
{ label: 'Dutch', value: 'nl' },
{ label: 'English', value: 'en' },
{ label: 'Estonian', value: 'et' },
{ label: 'Finnish', value: 'fi' },
{ label: 'French', value: 'fr' },
{ label: 'Galician', value: 'gl' },
{ label: 'German', value: 'de' },
{ label: 'Greek', value: 'el' },
{ label: 'Hebrew', value: 'he' },
{ label: 'Hindi', value: 'hi' },
{ label: 'Hungarian', value: 'hu' },
{ label: 'Icelandic', value: 'is' },
{ label: 'Indonesian', value: 'id' },
{ label: 'Italian', value: 'it' },
{ label: 'Japanese', value: 'ja' },
{ label: 'Kannada', value: 'kn' },
{ label: 'Kazakh', value: 'kk' },
{ label: 'Korean', value: 'ko' },
{ label: 'Latvian', value: 'lv' },
{ label: 'Lithuanian', value: 'lt' },
{ label: 'Macedonian', value: 'mk' },
{ label: 'Malay', value: 'ms' },
{ label: 'Marathi', value: 'mr' },
{ label: 'Maori', value: 'mi' },
{ label: 'Nepali', value: 'ne' },
{ label: 'Norwegian', value: 'no' },
{ label: 'Persian', value: 'fa' },
{ label: 'Polish', value: 'pl' },
{ label: 'Portuguese', value: 'pt' },
{ label: 'Romanian', value: 'ro' },
{ label: 'Russian', value: 'ru' },
{ label: 'Serbian', value: 'sr' },
{ label: 'Slovak', value: 'sk' },
{ label: 'Slovenian', value: 'sl' },
{ label: 'Spanish', value: 'es' },
{ label: 'Swahili', value: 'sw' },
{ label: 'Swedish', value: 'sv' },
{ label: 'Tagalog', value: 'tl' },
{ label: 'Tamil', value: 'ta' },
{ label: 'Thai', value: 'th' },
{ label: 'Turkish', value: 'tr' },
{ label: 'Ukrainian', value: 'uk' },
{ label: 'Urdu', value: 'ur' },
{ label: 'Vietnamese', value: 'vi' },
{ label: 'Welsh', value: 'cy' }
];

View File

@ -3,7 +3,7 @@
import { ModeWatcher } from 'mode-watcher';
import { ThemeWrapper, updateTheme } from '@kksh/svelte5';
import { onMount } from 'svelte';
import { ui } from '@kksh/api/ui/iframe';
import { ui } from '@kksh/api/ui/custom';
import { Sidebar } from '@kksh/svelte5';
import AppSidebar from '$lib/components/app-sidebar.svelte';

View File

@ -1,14 +1,19 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button, Input, Label, Textarea } from '@kksh/svelte5';
import { ui, event, fs, dialog, shell, kv, clipboard, toast } from '@kksh/api/ui/iframe';
import { onMount, tick } from 'svelte';
import { Button, Input, Label, Textarea, Command, Popover } from '@kksh/svelte5';
import { ui, event, fs, dialog, shell, kv, clipboard, toast } from '@kksh/api/ui/custom';
import type { API } from '../api.types';
import { goto } from '$app/navigation';
import { languageCodes } from '@/constants';
import { Check, ChevronsUpDown } from 'lucide-svelte';
import { cn } from '@/utils';
let filepath = $state('');
let openaiKey = $state('');
let transcription = $state('');
let transcribing = $state(false);
let language = $state('en');
let languageSelectOpen = $state(false);
onMount(() => {
kv.get('OPENAI_API_KEY').then((key) => {
@ -83,7 +88,7 @@
const { api, process } = rpc;
transcribing = true;
return api
.transcribe(filepath, 'en')
.transcribe(filepath, language)
.then((text) => {
console.log(text);
transcription = text;
@ -97,6 +102,13 @@
transcribing = false;
});
}
let triggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusTrigger() {
languageSelectOpen = false;
tick().then(() => {
triggerRef.focus();
});
}
</script>
<main class="container flex flex-col gap-2">
@ -105,6 +117,46 @@
<Input bind:value={filepath} />
<Button onclick={pickFile}>Pick File</Button>
</div>
<Label>Language</Label>
<Popover.Root bind:open={languageSelectOpen}>
<Popover.Trigger bind:ref={triggerRef}>
{#snippet child({ props }: { props: any })}
<Button
variant="outline"
class="w-[200px] justify-between"
{...props}
role="combobox"
aria-expanded={open}
>
{languageCodes.find((l) => l.value === language)?.label || 'Select a language...'}
<ChevronsUpDown class="opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0">
<Command.Root>
<Command.Input placeholder="Search framework..." />
<Command.List>
<Command.Empty>No language found.</Command.Empty>
<Command.Group>
{#each languageCodes as lang}
<Command.Item
value={lang.label}
onSelect={() => {
language = lang.value;
closeAndFocusTrigger();
}}
>
<Check class={cn(language !== lang.value && 'text-transparent')} />
{lang.label}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
<Button disabled={transcribing || !filepath} class="w-full" onclick={transcribe}>
Transcribe
</Button>

View File

@ -1,6 +1,6 @@
<script>
import { Alert, Button, ThemeWrapper, Input, Label } from '@kksh/svelte5';
import { ui, kv, toast } from '@kksh/api/ui/iframe';
import { ui, kv, toast } from '@kksh/api/ui/custom';
import { onMount } from 'svelte';
let apiKey = $state('');

View File

@ -0,0 +1,143 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { Button, Input, Label, Textarea, Command, Popover } from '@kksh/svelte5';
import { event, fs, dialog, shell, kv, clipboard, toast } from '@kksh/api/ui/custom';
import type { API } from '../../api.types';
import { goto } from '$app/navigation';
let filepath = $state('');
let openaiKey = $state('');
let transcription = $state('');
let transcribing = $state(false);
let language = $state('en');
let languageSelectOpen = $state(false);
onMount(() => {
kv.get('OPENAI_API_KEY').then((key) => {
if (!key) {
toast.warning('Please enter your OpenAI API key');
return goto('/preferences');
}
openaiKey = key;
});
event.onDragDrop((payload) => {
payload.paths;
});
});
async function getAPI() {
const { rpcChannel, process, command } = await shell.createDenoRpcChannel<object, API>(
'$EXTENSION/deno-src/index.ts',
[],
{
allowEnv: [
'OPENAI_API_KEY',
'OPENAI_BASE_URL',
'OPENAI_ORG_ID',
'OPENAI_PROJECT_ID',
'DEBUG'
],
allowNet: ['api.openai.com'],
allowRead: [filepath],
env: {
OPENAI_API_KEY: openaiKey
}
},
{}
);
return { rpcChannel, process, command, api: rpcChannel.getAPI() };
}
function pickFile() {
dialog
.open({
directory: false,
filters: [
{ extensions: ['mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav', 'webm'], name: 'Audio Files' }
]
})
.then((path: string) => {
if (!path) {
return toast.error('No file selected');
}
filepath = path;
})
.catch((err: any) => {
toast.error('Failed to pick file', { description: err.message });
return null;
});
}
async function transcribe() {
if (!filepath) {
return toast.error('No file selected');
}
await fs.exists(filepath).then((exists) => {
if (!exists) {
return toast.error('File does not exist');
}
});
toast.info('Transcribing...');
const rpc = await getAPI();
if (!rpc) {
return;
}
const { api, process } = rpc;
transcribing = true;
return api
.transcribe(filepath, language)
.then((text) => {
console.log(text);
transcription = text;
toast.success('Transcription completed');
})
.catch((err) => {
toast.error('Failed to transcribe', { description: err.message });
})
.finally(() => {
process.kill();
transcribing = false;
});
}
let triggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusTrigger() {
languageSelectOpen = false;
tick().then(() => {
triggerRef.focus();
});
}
</script>
<main class="container flex flex-col gap-2">
<p>Translate audio file to English</p>
<Label>Pick an Audio File</Label>
<div class="flex gap-1">
<Input bind:value={filepath} />
<Button onclick={pickFile}>Pick File</Button>
</div>
<Button disabled={transcribing || !filepath} class="w-full" onclick={transcribe}>
Transcribe and Translate
</Button>
{#if transcribing}
<h2 class="text-center">Transcribing...</h2>
{:else}
<Textarea bind:value={transcription} class="min-h-64 w-full" />
<Button
class="w-full"
disabled={!transcription}
onclick={() => {
clipboard
.writeText(transcription)
.then(() => {
toast.success('Copied to clipboard');
})
.catch((err) => {
toast.error('Failed to copy to clipboard', { description: err.message });
});
}}
>
Copy
</Button>
{/if}
</main>