transcribe followup
Browse files
src/lib/components/chat/ChatInput.svelte
CHANGED
|
@@ -402,7 +402,7 @@
|
|
| 402 |
|
| 403 |
{#if $enabledServersCount > 0}
|
| 404 |
<div
|
| 405 |
-
class="ml-
|
| 406 |
class:grayscale={!modelSupportsTools}
|
| 407 |
class:opacity-60={!modelSupportsTools}
|
| 408 |
class:cursor-help={!modelSupportsTools}
|
|
|
|
| 402 |
|
| 403 |
{#if $enabledServersCount > 0}
|
| 404 |
<div
|
| 405 |
+
class="ml-1.5 inline-flex h-8 items-center gap-1.5 rounded-full border border-blue-500/10 bg-blue-600/10 pl-2 pr-1 text-xs font-semibold text-blue-700 dark:bg-blue-600/20 dark:text-blue-400 sm:h-7"
|
| 406 |
class:grayscale={!modelSupportsTools}
|
| 407 |
class:opacity-60={!modelSupportsTools}
|
| 408 |
class:cursor-help={!modelSupportsTools}
|
src/lib/components/chat/ChatWindow.svelte
CHANGED
|
@@ -653,7 +653,7 @@
|
|
| 653 |
{#if transcriptionEnabled}
|
| 654 |
<button
|
| 655 |
type="button"
|
| 656 |
-
class="btn absolute bottom-2 right-10 mr-1 size-8 self-end rounded-full border bg-white/50 text-gray-500 transition-none hover:bg-gray-50 hover:text-gray-700 dark:border-transparent dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500 dark:hover:text-white sm:right-9 sm:size-7"
|
| 657 |
disabled={isReadOnly}
|
| 658 |
onclick={() => {
|
| 659 |
isRecording = true;
|
|
|
|
| 653 |
{#if transcriptionEnabled}
|
| 654 |
<button
|
| 655 |
type="button"
|
| 656 |
+
class="btn absolute bottom-2 right-10 mr-1.5 size-8 self-end rounded-full border bg-white/50 text-gray-500 transition-none hover:bg-gray-50 hover:text-gray-700 dark:border-transparent dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500 dark:hover:text-white sm:right-9 sm:size-7"
|
| 657 |
disabled={isReadOnly}
|
| 658 |
onclick={() => {
|
| 659 |
isRecording = true;
|
src/lib/components/chat/VoiceRecorder.svelte
CHANGED
|
@@ -100,44 +100,52 @@
|
|
| 100 |
}
|
| 101 |
}
|
| 102 |
|
| 103 |
-
function stopRecording(): Blob | null {
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
mediaStream.getTracks().forEach((track) => track.stop());
|
| 113 |
-
mediaStream = null;
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
// Close audio context
|
| 117 |
-
if (audioContext) {
|
| 118 |
-
audioContext.close();
|
| 119 |
-
audioContext = null;
|
| 120 |
-
}
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
return new Blob(audioChunks, { type: mimeType });
|
| 132 |
}
|
| 133 |
|
| 134 |
-
function handleCancel() {
|
| 135 |
-
stopRecording();
|
| 136 |
oncancel();
|
| 137 |
}
|
| 138 |
|
| 139 |
-
function handleConfirm() {
|
| 140 |
-
const audioBlob = stopRecording();
|
| 141 |
if (audioBlob && audioBlob.size > 0) {
|
| 142 |
if (isTouchDevice) {
|
| 143 |
onsend(audioBlob);
|
|
@@ -154,6 +162,7 @@
|
|
| 154 |
});
|
| 155 |
|
| 156 |
onDestroy(() => {
|
|
|
|
| 157 |
stopRecording();
|
| 158 |
});
|
| 159 |
</script>
|
|
|
|
| 100 |
}
|
| 101 |
}
|
| 102 |
|
| 103 |
+
function stopRecording(): Promise<Blob | null> {
|
| 104 |
+
return new Promise((resolve) => {
|
| 105 |
+
stopVisualization();
|
| 106 |
+
|
| 107 |
+
// Stop all audio tracks
|
| 108 |
+
if (mediaStream) {
|
| 109 |
+
mediaStream.getTracks().forEach((track) => track.stop());
|
| 110 |
+
mediaStream = null;
|
| 111 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
+
// Close audio context
|
| 114 |
+
if (audioContext) {
|
| 115 |
+
audioContext.close();
|
| 116 |
+
audioContext = null;
|
| 117 |
+
}
|
| 118 |
+
analyser = null;
|
| 119 |
+
|
| 120 |
+
if (!mediaRecorder || mediaRecorder.state === "inactive") {
|
| 121 |
+
mediaRecorder = null;
|
| 122 |
+
resolve(
|
| 123 |
+
audioChunks.length > 0
|
| 124 |
+
? new Blob(audioChunks, { type: audioChunks[0]?.type || "audio/webm" })
|
| 125 |
+
: null
|
| 126 |
+
);
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
|
| 130 |
+
// Wait for final data before resolving
|
| 131 |
+
mediaRecorder.onstop = () => {
|
| 132 |
+
const mimeType = audioChunks[0]?.type || "audio/webm";
|
| 133 |
+
const blob = audioChunks.length > 0 ? new Blob(audioChunks, { type: mimeType }) : null;
|
| 134 |
+
mediaRecorder = null;
|
| 135 |
+
resolve(blob);
|
| 136 |
+
};
|
| 137 |
|
| 138 |
+
mediaRecorder.stop();
|
| 139 |
+
});
|
|
|
|
| 140 |
}
|
| 141 |
|
| 142 |
+
async function handleCancel() {
|
| 143 |
+
await stopRecording();
|
| 144 |
oncancel();
|
| 145 |
}
|
| 146 |
|
| 147 |
+
async function handleConfirm() {
|
| 148 |
+
const audioBlob = await stopRecording();
|
| 149 |
if (audioBlob && audioBlob.size > 0) {
|
| 150 |
if (isTouchDevice) {
|
| 151 |
onsend(audioBlob);
|
|
|
|
| 162 |
});
|
| 163 |
|
| 164 |
onDestroy(() => {
|
| 165 |
+
// Fire and forget - cleanup happens but we don't wait
|
| 166 |
stopRecording();
|
| 167 |
});
|
| 168 |
</script>
|
src/routes/api/transcribe/+server.ts
CHANGED
|
@@ -29,7 +29,10 @@ export async function POST({ request, locals }) {
|
|
| 29 |
throw error(401, "Authentication required");
|
| 30 |
}
|
| 31 |
|
| 32 |
-
const
|
|
|
|
|
|
|
|
|
|
| 33 |
const isAllowed = ALLOWED_CONTENT_TYPES.some((type) => contentType.includes(type));
|
| 34 |
|
| 35 |
if (!isAllowed) {
|
|
|
|
| 29 |
throw error(401, "Authentication required");
|
| 30 |
}
|
| 31 |
|
| 32 |
+
const rawContentType = request.headers.get("content-type") || "";
|
| 33 |
+
// Normalize content-type: Safari sends "audio/webm; codecs=opus" (with space)
|
| 34 |
+
// but HF API expects "audio/webm;codecs=opus" (no space)
|
| 35 |
+
const contentType = rawContentType.replace(/;\s+/g, ";");
|
| 36 |
const isAllowed = ALLOWED_CONTENT_TYPES.some((type) => contentType.includes(type));
|
| 37 |
|
| 38 |
if (!isAllowed) {
|