Compare commits

..

5 Commits

8 changed files with 50 additions and 42 deletions
-4
View File
@@ -6,10 +6,6 @@ OpenRouterAPIKey=
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5 # Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
# OpenRouterModel=anthropic/claude-3.5-haiku # OpenRouterModel=anthropic/claude-3.5-haiku
# Speech-to-Text Transcription Service (local Parakeet container endpoint)
# Defaults to: http://localhost:5092/v1/audio/transcriptions (or http://parakeet:5092/v1/audio/transcriptions in Docker)
# PARAKEET_URL=http://localhost:5092/v1/audio/transcriptions
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs) # DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
# Free plan keys use api-free.deepl.com automatically (suffix :fx) # Free plan keys use api-free.deepl.com automatically (suffix :fx)
DeepLAPIKey= DeepLAPIKey=
@@ -7,6 +7,7 @@ import { formatEventSummary } from '../utils/formatEventSummary.js'
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx' import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js' import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface EventRemarksCellProps { interface EventRemarksCellProps {
event: LogEventPayload event: LogEventPayload
@@ -66,9 +67,17 @@ export default function EventRemarksCell({
throw new Error('Transcription returned empty text') throw new Error('Transcription returned empty text')
} }
await updateVoiceMemoTranscript(logbookId, voiceId, text) await updateVoiceMemoTranscript(logbookId, voiceId, text)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'manual'
})
} catch (err) { } catch (err) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
console.error('[EventRemarksCell] Transcription failed:', err) console.error('[EventRemarksCell] Transcription failed:', err)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'manual'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn')) void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} finally { } finally {
setTranscribing(false) setTranscribing(false)
+9 -1
View File
@@ -885,9 +885,17 @@ export default function LiveLogView({
setVoiceCaption('') setVoiceCaption('')
showUndo('voice') showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' }) trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
if (transcriptionError) { if (transcriptionError) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'auto'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn')) void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} else {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'auto'
})
} }
} catch (err: unknown) { } catch (err: unknown) {
console.error('Live log voice save failed:', err) console.error('Live log voice save failed:', err)
+1
View File
@@ -42,6 +42,7 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened', LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged', LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded', VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched', OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated', AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft', PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
-8
View File
@@ -33,7 +33,6 @@ services:
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-} OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-} OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku} OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
SESSION_SECRET: ${SESSION_SECRET:-} SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
@@ -67,13 +66,6 @@ services:
backend: backend:
condition: service_healthy condition: service_healthy
parakeet:
image: ghcr.io/achetronic/parakeet:latest
container_name: daagbox-staging-parakeet
restart: always
ports:
- "5092:5092"
volumes: volumes:
pgdata: pgdata:
name: daagbox-staging-pgdata name: daagbox-staging-pgdata
-8
View File
@@ -34,7 +34,6 @@ services:
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-} OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-} OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku} OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
SESSION_SECRET: ${SESSION_SECRET:-} SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
@@ -68,13 +67,6 @@ services:
backend: backend:
condition: service_healthy condition: service_healthy
parakeet:
image: ghcr.io/achetronic/parakeet:latest
container_name: daagbox-prod-parakeet
restart: always
ports:
- "5092:5092"
volumes: volumes:
pgdata: pgdata:
name: daagbox-prod-pgdata name: daagbox-prod-pgdata
+2
View File
@@ -47,6 +47,7 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — | | CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` | | Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` | | Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
| Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) | | OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — | | AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) | | Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
@@ -161,6 +162,7 @@ trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' }) trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' }) trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' }) trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, { status: 'success', mode: 'auto' })
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' }) trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true }) trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true }) trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
+29 -21
View File
@@ -3,8 +3,6 @@ import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js' import { requireUser } from '../middleware/auth.js'
const router = Router() const router = Router()
const PARAKEET_URL = process.env.PARAKEET_URL || 'http://localhost:5092/v1/audio/transcriptions'
const MAX_ATTEMPTS_PER_ENTRY = 3 const MAX_ATTEMPTS_PER_ENTRY = 3
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku' const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions' const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
@@ -238,51 +236,61 @@ router.post('/transcribe', async (req: any, res) => {
return res.status(400).json({ error: 'audioDataUrl is required' }) return res.status(400).json({ error: 'audioDataUrl is required' })
} }
const match = audioDataUrl.match(/^data:([^;]+);base64,(.+)$/) const match = audioDataUrl.match(/^data:(.+);base64,(.+)$/)
if (!match) { if (!match) {
return res.status(400).json({ error: 'Invalid audio data URL format' }) return res.status(400).json({ error: 'Invalid audio data URL format' })
} }
const [, mimeType, base64Data] = match const [, fullMimeType, base64Data] = match
const buffer = Buffer.from(base64Data, 'base64') const mimeType = fullMimeType.split(';')[0]
let ext = 'webm' let ext = 'webm'
if (mimeType.includes('mp4')) ext = 'mp4' if (mimeType.includes('mp4')) ext = 'mp4'
else if (mimeType.includes('ogg')) ext = 'ogg' else if (mimeType.includes('ogg')) ext = 'ogg'
else if (mimeType.includes('wav')) ext = 'wav' else if (mimeType.includes('wav')) ext = 'wav'
const filename = `audio.${ext}` const apiKey = resolveOpenRouterApiKey()
const file = new File([buffer], filename, { type: mimeType }) if (!apiKey) {
console.warn('[server] OpenRouter API key not configured, transcription unavailable')
return res.status(503).json({ error: 'Transcription service not configured' })
}
const formData = new FormData() console.log(`[server] Forwarding ASR request to OpenRouter (${ext}, ${base64Data.length} chars)`)
formData.append('file', file)
console.log(`[server] Forwarding ASR request to ${PARAKEET_URL} (${filename}, ${buffer.length} bytes)`)
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000) const timeoutId = setTimeout(() => controller.abort(), 30000)
try { try {
const parakeetRes = await fetch(PARAKEET_URL, { const openRouterRes = await fetch('https://openrouter.ai/api/v1/audio/transcriptions', {
method: 'POST', method: 'POST',
body: formData, headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'openai/whisper-large-v3-turbo',
input_audio: {
data: base64Data,
format: ext
}
}),
signal: controller.signal signal: controller.signal
}) })
if (!parakeetRes.ok) { if (!openRouterRes.ok) {
const errorText = await parakeetRes.text().catch(() => '') const errorText = await openRouterRes.text().catch(() => '')
console.error(`[server] Parakeet ASR error response (status=${parakeetRes.status}):`, errorText) console.error(`[server] OpenRouter ASR error response (status=${openRouterRes.status}):`, errorText)
throw new Error(`Parakeet returned status ${parakeetRes.status}`) throw new Error(`OpenRouter returned status ${openRouterRes.status}`)
} }
const data: any = await parakeetRes.json() const data: any = await openRouterRes.json()
const text = (data?.text || '').trim() const text = (data?.text || '').trim()
console.log(`[server] ASR completed successfully: "${text}"`) console.log(`[server] OpenRouter ASR completed successfully: "${text}"`)
return res.json({ text }) return res.json({ text })
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
console.error('[server] Parakeet ASR request timed out') console.error('[server] OpenRouter ASR request timed out')
return res.status(504).json({ error: 'Transcription request timed out' }) return res.status(504).json({ error: 'Transcription request timed out' })
} }
throw error throw error