Enhance audio processing capabilities: add FFmpeg for audio transcoding, update database schema, and improve UI for audio file uploads
This commit is contained in:
+4
-1
@@ -2,7 +2,10 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /srv/faerro-kb
|
WORKDIR /srv/faerro-kb
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY frontend ./frontend
|
COPY frontend ./frontend
|
||||||
|
|
||||||
|
|||||||
@@ -27,21 +27,23 @@ In Safari on iPhone, open the HTTPS address and use **Share > Add to Home Screen
|
|||||||
## Capture and offline behavior
|
## Capture and offline behavior
|
||||||
|
|
||||||
- Recording data and its timestamp are committed to IndexedDB before upload is attempted.
|
- Recording data and its timestamp are committed to IndexedDB before upload is attempted.
|
||||||
|
- Microphone recordings use the audio format supported by the browser (commonly M4A/AAC on iPhone Safari or Opus/WebM elsewhere). You can also add an audio file from the device; both sources remain queued locally until the server confirms storage.
|
||||||
|
- The server keeps each uploaded original and creates a mono PCM WAV copy at 16-bit / 22.05 kHz for transcription. FFmpeg performs this conversion locally in the container. After a non-empty transcript is stored, both audio files are deleted; failed or empty transcriptions keep the audio for recovery.
|
||||||
- Uploads retry when the app opens, returns to the foreground, or the browser reports a connection. A recording is removed from the phone's queue only after the server confirms it was stored.
|
- Uploads retry when the app opens, returns to the foreground, or the browser reports a connection. A recording is removed from the phone's queue only after the server confirms it was stored.
|
||||||
- Service worker caching keeps the app shell available offline. Searching the server-side archive requires a connection.
|
- Service worker caching keeps the app shell available offline. Searching the server-side archive requires a connection.
|
||||||
- iOS may suspend a PWA and can evict website data under storage pressure. Background uploads are not guaranteed; reopen the PWA while online to resume. Keep the phone powered and avoid clearing Safari website data for stronger practical retention.
|
- iOS may suspend a PWA and can evict website data under storage pressure. Background uploads are not guaranteed; reopen the PWA while online to resume. Keep the phone powered and avoid clearing Safari website data for stronger practical retention.
|
||||||
|
|
||||||
## Local speech-to-text
|
## Local speech-to-text
|
||||||
|
|
||||||
The API can use a local `whisper.cpp` server when `WHISPER_SERVER_URL` is configured. Audio is sent only to that local endpoint; with the variable unset, uploads remain stored as `awaiting_transcription` and can still be searched after a transcript is edited or populated.
|
The API can use a local `whisper.cpp` server when `WHISPER_SERVER_URL` is configured. Audio is sent only to that local endpoint; with the variable unset, uploads remain stored as `awaiting_transcription` until a non-empty transcript is entered and saved. Saving that transcript deletes both server-side audio files.
|
||||||
|
|
||||||
For NVIDIA GPU transcription, run a CUDA-enabled `whisper.cpp` server with a model on the same machine, then set `WHISPER_SERVER_URL` to its local inference endpoint, for example `http://whisper:8080/inference`. Use a model that fits the RTX 3070's VRAM; start with `small` or a quantized `medium` model and measure with your audio/language. Do not expose the transcription server outside the private Docker network. See the [whisper.cpp NVIDIA instructions](https://github.com/ggml-org/whisper.cpp#nvidia-gpu-support) for CUDA builds.
|
For NVIDIA GPU transcription, run a CUDA-enabled `whisper.cpp` server with a model on the same machine, then set `WHISPER_SERVER_URL` to its local inference endpoint, for example `http://whisper:8080/inference`. Use a model that fits the RTX 3070's VRAM; start with `small` or a quantized `medium` model and measure with your audio/language. Do not expose the transcription server outside the private Docker network. See the [whisper.cpp NVIDIA instructions](https://github.com/ggml-org/whisper.cpp#nvidia-gpu-support) for CUDA builds.
|
||||||
|
|
||||||
This version keeps raw audio and transcript separate. A future local correction model can propose punctuation and name fixes without replacing the original transcription. Proper-name matching and offline geolocation data are not enabled yet.
|
The transcript remains in SQLite after the audio files are deleted. A future local correction model can propose punctuation and name fixes without replacing the stored transcription. Proper-name matching and offline geolocation data are not enabled yet.
|
||||||
|
|
||||||
## Data and backups
|
## Data and backups
|
||||||
|
|
||||||
Docker persists SQLite metadata in `./data/voice-kb.sqlite3` and original audio in `./data/audio/`. Back up the whole `./data` directory while the service is stopped or use a SQLite-aware backup procedure. Model files should also be stored locally and backed up separately if needed.
|
Docker persists SQLite metadata in `./data/voice-kb.sqlite3` and audio awaiting transcription in `./data/audio/`. After a non-empty transcript is saved, its audio files are removed. Back up the whole `./data` directory while the service is stopped or use a SQLite-aware backup procedure. Model files should also be stored locally and backed up separately if needed.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+77
-7
@@ -1,6 +1,9 @@
|
|||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -44,10 +47,26 @@ def initialize_db() -> None:
|
|||||||
"""CREATE VIRTUAL TABLE IF NOT EXISTS note_search
|
"""CREATE VIRTUAL TABLE IF NOT EXISTS note_search
|
||||||
USING fts5(note_id UNINDEXED, transcript)"""
|
USING fts5(note_id UNINDEXED, transcript)"""
|
||||||
)
|
)
|
||||||
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(notes)")}
|
||||||
|
if "original_audio_path" not in columns:
|
||||||
|
connection.execute("ALTER TABLE notes ADD COLUMN original_audio_path TEXT")
|
||||||
|
|
||||||
|
|
||||||
initialize_db()
|
initialize_db()
|
||||||
|
|
||||||
|
def transcode_audio(source_path: Path, output_path: Path) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
||||||
|
"-i", str(source_path), "-vn", "-ac", "1", "-ar", "22050",
|
||||||
|
"-c:a", "pcm_s16le", str(output_path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_transcript(note_id: str, transcript: str, status: str) -> None:
|
def update_transcript(note_id: str, transcript: str, status: str) -> None:
|
||||||
with connect_db() as connection:
|
with connect_db() as connection:
|
||||||
@@ -63,6 +82,23 @@ def update_transcript(note_id: str, transcript: str, status: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_transcribed_audio(note_id: str) -> None:
|
||||||
|
with connect_db() as connection:
|
||||||
|
note = connection.execute(
|
||||||
|
"SELECT audio_path, original_audio_path FROM notes WHERE id = ?", (note_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not note:
|
||||||
|
return
|
||||||
|
for audio_path in (note["audio_path"], note["original_audio_path"]):
|
||||||
|
if audio_path:
|
||||||
|
Path(audio_path).unlink(missing_ok=True)
|
||||||
|
with connect_db() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE notes SET audio_path = '', original_audio_path = NULL WHERE id = ?",
|
||||||
|
(note_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def transcribe_note(note_id: str, audio_path: Path) -> None:
|
async def transcribe_note(note_id: str, audio_path: Path) -> None:
|
||||||
if not WHISPER_SERVER_URL:
|
if not WHISPER_SERVER_URL:
|
||||||
return
|
return
|
||||||
@@ -76,9 +112,12 @@ async def transcribe_note(note_id: str, audio_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
transcript = response.json().get("text", "").strip()
|
transcript = response.json().get("text", "").strip()
|
||||||
update_transcript(note_id, transcript, "transcribed" if transcript else "empty_transcript")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
update_transcript(note_id, "", "transcription_failed")
|
update_transcript(note_id, "", "transcription_failed")
|
||||||
|
return
|
||||||
|
update_transcript(note_id, transcript, "transcribed" if transcript else "empty_transcript")
|
||||||
|
if transcript:
|
||||||
|
remove_transcribed_audio(note_id)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
@@ -102,15 +141,39 @@ async def create_note(
|
|||||||
if existing:
|
if existing:
|
||||||
return {"id": note_id, "status": existing["status"]}
|
return {"id": note_id, "status": existing["status"]}
|
||||||
suffix = Path(audio.filename or "recording.webm").suffix.lower()
|
suffix = Path(audio.filename or "recording.webm").suffix.lower()
|
||||||
if not suffix or len(suffix) > 10:
|
if not re.fullmatch(r"\.[a-z0-9]{1,9}", suffix):
|
||||||
suffix = ".audio"
|
suffix = ".audio"
|
||||||
audio_path = AUDIO_DIR / f"{note_id}{suffix}"
|
original_audio_path = AUDIO_DIR / f"{note_id}-original{suffix}"
|
||||||
|
audio_path = AUDIO_DIR / f"{note_id}.wav"
|
||||||
|
temporary_output = None
|
||||||
try:
|
try:
|
||||||
with audio_path.open("wb") as destination:
|
with original_audio_path.open("wb") as destination:
|
||||||
while chunk := await audio.read(1024 * 1024):
|
while chunk := await audio.read(1024 * 1024):
|
||||||
destination.write(chunk)
|
destination.write(chunk)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".wav", dir=AUDIO_DIR, delete=False) as temporary_file:
|
||||||
|
temporary_output = Path(temporary_file.name)
|
||||||
|
await asyncio.to_thread(transcode_audio, original_audio_path, temporary_output)
|
||||||
|
temporary_output.replace(audio_path)
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
original_audio_path.unlink(missing_ok=True)
|
||||||
|
if temporary_output:
|
||||||
|
temporary_output.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Audio conversion is unavailable") from error
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
original_audio_path.unlink(missing_ok=True)
|
||||||
|
if temporary_output:
|
||||||
|
temporary_output.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported or invalid audio file") from error
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
original_audio_path.unlink(missing_ok=True)
|
||||||
|
if temporary_output:
|
||||||
|
temporary_output.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=422, detail="Audio conversion timed out") from error
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
original_audio_path.unlink(missing_ok=True)
|
||||||
audio_path.unlink(missing_ok=True)
|
audio_path.unlink(missing_ok=True)
|
||||||
|
if temporary_output:
|
||||||
|
temporary_output.unlink(missing_ok=True)
|
||||||
raise HTTPException(status_code=500, detail="Could not store audio") from error
|
raise HTTPException(status_code=500, detail="Could not store audio") from error
|
||||||
finally:
|
finally:
|
||||||
await audio.close()
|
await audio.close()
|
||||||
@@ -126,8 +189,10 @@ async def create_note(
|
|||||||
status = "awaiting_transcription" if not WHISPER_SERVER_URL else "transcribing"
|
status = "awaiting_transcription" if not WHISPER_SERVER_URL else "transcribing"
|
||||||
with connect_db() as connection:
|
with connect_db() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT OR IGNORE INTO notes (id, created_at, filename, audio_path, status) VALUES (?, ?, ?, ?, ?)",
|
"""INSERT OR IGNORE INTO notes
|
||||||
(note_id, created_at, audio.filename or "recording.audio", str(audio_path), status),
|
(id, created_at, filename, audio_path, original_audio_path, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
|
(note_id, created_at, audio.filename or "recording.audio", str(audio_path), str(original_audio_path), status),
|
||||||
)
|
)
|
||||||
if WHISPER_SERVER_URL:
|
if WHISPER_SERVER_URL:
|
||||||
background_tasks.add_task(transcribe_note, note_id, audio_path)
|
background_tasks.add_task(transcribe_note, note_id, audio_path)
|
||||||
@@ -173,13 +238,16 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
|
|||||||
def delete_note(note_id: str) -> dict[str, str]:
|
def delete_note(note_id: str) -> dict[str, str]:
|
||||||
with connect_db() as connection:
|
with connect_db() as connection:
|
||||||
note = connection.execute(
|
note = connection.execute(
|
||||||
"SELECT audio_path FROM notes WHERE id = ?", (note_id,)
|
"SELECT audio_path, original_audio_path FROM notes WHERE id = ?", (note_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not note:
|
if not note:
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
|
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
|
||||||
connection.execute("DELETE FROM notes WHERE id = ?", (note_id,))
|
connection.execute("DELETE FROM notes WHERE id = ?", (note_id,))
|
||||||
|
if note["audio_path"]:
|
||||||
Path(note["audio_path"]).unlink(missing_ok=True)
|
Path(note["audio_path"]).unlink(missing_ok=True)
|
||||||
|
if note["original_audio_path"]:
|
||||||
|
Path(note["original_audio_path"]).unlink(missing_ok=True)
|
||||||
return {"id": note_id, "status": "deleted"}
|
return {"id": note_id, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@@ -191,6 +259,8 @@ def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
|
|||||||
if not exists:
|
if not exists:
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription")
|
update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription")
|
||||||
|
if transcript:
|
||||||
|
remove_transcribed_audio(note_id)
|
||||||
return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"}
|
return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
faerro-kb:
|
faerro-kb:
|
||||||
image: gitea.faerro.it/andrea/faerro-kb:0.0.1
|
image: gitea.faerro.it/andrea/faerro-kb:latest
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-80}:80"
|
- "${HTTP_PORT:-80}:80"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+32
-3
@@ -2,7 +2,7 @@ const DB_NAME = 'faerro-kb-local';
|
|||||||
const STORE_NAME = 'recordings';
|
const STORE_NAME = 'recordings';
|
||||||
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
||||||
const elements = Object.fromEntries([
|
const elements = Object.fromEntries([
|
||||||
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message',
|
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message', 'audio-file-input',
|
||||||
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
|
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
|
||||||
'network-state', 'storage-status',
|
'network-state', 'storage-status',
|
||||||
].map((id) => [id, document.getElementById(id)]));
|
].map((id) => [id, document.getElementById(id)]));
|
||||||
@@ -53,7 +53,9 @@ function updateNetwork() {
|
|||||||
const online = navigator.onLine;
|
const online = navigator.onLine;
|
||||||
elements['network-state'].classList.toggle('online', online);
|
elements['network-state'].classList.toggle('online', online);
|
||||||
elements['network-state'].innerHTML = `<i></i>${online ? 'Connected' : 'Offline'}`;
|
elements['network-state'].innerHTML = `<i></i>${online ? 'Connected' : 'Offline'}`;
|
||||||
|
if (elements['storage-status']) {
|
||||||
elements['storage-status'].textContent = online ? 'Pending audio stays here until server confirmation.' : 'Offline: new audio is saved on this device.';
|
elements['storage-status'].textContent = online ? 'Pending audio stays here until server confirmation.' : 'Offline: new audio is saved on this device.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(milliseconds) {
|
function formatDuration(milliseconds) {
|
||||||
@@ -170,7 +172,7 @@ async function saveRecording() {
|
|||||||
setMessage('No audio was captured. Please try again.', true);
|
setMessage('No audio was captured. Please try again.', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const extension = blob.type.includes('webm') ? 'webm' : blob.type.includes('ogg') ? 'ogg' : 'm4a';
|
const extension = blob.type.includes('webm') ? 'webm' : blob.type.includes('ogg') ? 'ogg' : blob.type.includes('mp4') ? 'm4a' : 'audio';
|
||||||
const createdAt = new Date(recordingStartedAt).toISOString();
|
const createdAt = new Date(recordingStartedAt).toISOString();
|
||||||
const recording = {
|
const recording = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -190,6 +192,27 @@ async function saveRecording() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queueAudioFile(file) {
|
||||||
|
const createdAtMs = Date.now();
|
||||||
|
const createdAt = new Date(createdAtMs).toISOString();
|
||||||
|
const recording = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
blob: file,
|
||||||
|
createdAt,
|
||||||
|
filename: file.name,
|
||||||
|
durationMs: 0,
|
||||||
|
createdAtMs,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await putQueued(recording);
|
||||||
|
setMessage(navigator.onLine ? 'Audio saved on device. Uploading now…' : 'Audio saved on device. It will upload when you reconnect.');
|
||||||
|
await renderQueue();
|
||||||
|
await syncQueue();
|
||||||
|
} catch {
|
||||||
|
setMessage('Could not save this file in browser storage. Check available device storage and try again.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function syncQueue() {
|
async function syncQueue() {
|
||||||
if (syncing || !navigator.onLine) return;
|
if (syncing || !navigator.onLine) return;
|
||||||
syncing = true;
|
syncing = true;
|
||||||
@@ -247,7 +270,7 @@ async function renderQueue() {
|
|||||||
status.textContent = navigator.onLine ? 'WAITING TO SYNC' : 'SAVED OFFLINE';
|
status.textContent = navigator.onLine ? 'WAITING TO SYNC' : 'SAVED OFFLINE';
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'item-meta';
|
meta.className = 'item-meta';
|
||||||
meta.textContent = `${prettyDate(recording.createdAt)} · ${formatDuration(recording.durationMs)}`;
|
meta.textContent = `${prettyDate(recording.createdAt)}${recording.durationMs ? ` · ${formatDuration(recording.durationMs)}` : ''}`;
|
||||||
const remove = createDeleteButton(`Delete queued note ${recording.filename}`);
|
const remove = createDeleteButton(`Delete queued note ${recording.filename}`);
|
||||||
remove.disabled = syncing;
|
remove.disabled = syncing;
|
||||||
remove.addEventListener('click', async () => {
|
remove.addEventListener('click', async () => {
|
||||||
@@ -355,6 +378,12 @@ async function loadAwaitingNotes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
|
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
|
||||||
|
elements['audio-file-input'].addEventListener('change', async (event) => {
|
||||||
|
const [file] = event.target.files;
|
||||||
|
if (file) await queueAudioFile(file);
|
||||||
|
event.target.value = '';
|
||||||
|
});
|
||||||
|
elements['sync-button'].addEventListener('click', syncQueue);
|
||||||
elements['sync-button'].addEventListener('click', syncQueue);
|
elements['sync-button'].addEventListener('click', syncQueue);
|
||||||
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); });
|
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); });
|
||||||
window.addEventListener('offline', updateNetwork);
|
window.addEventListener('offline', updateNetwork);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
<circle cx="32" cy="32" r="32" fill="#355a43" />
|
<circle cx="32" cy="32" r="32" fill="#355a43" />
|
||||||
<text x="32" y="45" fill="#fffefa" font-family="Georgia, serif" font-size="28" text-anchor="middle">KB</text>
|
<text x="32" y="43" fill="#fff" font-family="Iowan Old Style, Palatino Linotype, Book Antiqua, Georgia, serif" font-size="34" font-weight="500" text-anchor="middle">KB</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 230 B After Width: | Height: | Size: 295 B |
+6
-1
@@ -13,7 +13,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/" aria-label="Faerro KB"><span class="brand-mark">V</span><span>Faerro <b>KB</b></span></a>
|
<a class="brand" href="/" aria-label="Faerro KB"><span class="brand-mark">KB</span><span>Faerro <b>KB</b></span></a>
|
||||||
<span id="network-state" class="network-state"><i></i> Checking</span>
|
<span id="network-state" class="network-state"><i></i> Checking</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -32,6 +32,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<time id="record-time">00:00</time>
|
<time id="record-time">00:00</time>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="audio-upload-row">
|
||||||
|
<label class="audio-upload-button" for="audio-file-input">↑ Upload audio</label>
|
||||||
|
<input id="audio-file-input" type="file" accept="audio/*,.mp3,.m4a,.aac,.ogg,.opus,.flac,.wav" />
|
||||||
|
<span>Files stay on this device until the server confirms storage.</span>
|
||||||
|
</div>
|
||||||
<div class="record-meter"><span id="record-meter-fill"></span></div>
|
<div class="record-meter"><span id="record-meter-fill"></span></div>
|
||||||
<div id="capture-message" class="capture-message" role="status"></div>
|
<div id="capture-message" class="capture-message" role="status"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+7
-1
@@ -18,7 +18,7 @@ body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(
|
|||||||
.topbar { height: 68px; padding: 0 max(24px, calc((100vw - 1040px) / 2)); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
|
.topbar { height: 68px; padding: 0 max(24px, calc((100vw - 1040px) / 2)); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
|
||||||
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ink); font-size: 15px; font-weight: 600; }
|
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ink); font-size: 15px; font-weight: 600; }
|
||||||
.brand b { color: var(--moss); }
|
.brand b { color: var(--moss); }
|
||||||
.brand-mark { width: 28px; height: 28px; border-radius: 50%; background: var(--moss-dark); color: #fff; display: grid; place-items: center; font: 500 15px var(--serif); }
|
.brand-mark { width: 28px; height: 28px; flex: 0 0 auto; border-radius: 50%; background: var(--moss-dark); color: #fff; display: grid; place-items: center; font: 500 15px var(--serif); }
|
||||||
.network-state { font: 11px var(--mono); color: var(--muted); display: flex; align-items: center; gap: 8px; }
|
.network-state { font: 11px var(--mono); color: var(--muted); display: flex; align-items: center; gap: 8px; }
|
||||||
.network-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); }
|
.network-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); }
|
||||||
.network-state.online i { background: #57986b; }
|
.network-state.online i { background: #57986b; }
|
||||||
@@ -44,6 +44,11 @@ h1 em { color: var(--moss); font-weight: 500; }
|
|||||||
.record-meter { height: 2px; max-width: 590px; margin-inline: auto; background: #e4e3dc; }
|
.record-meter { height: 2px; max-width: 590px; margin-inline: auto; background: #e4e3dc; }
|
||||||
.record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; }
|
.record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; }
|
||||||
.capture-message { height: 20px; max-width: 590px; margin-inline: auto; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; }
|
.capture-message { height: 20px; max-width: 590px; margin-inline: auto; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; }
|
||||||
|
.audio-upload-row { max-width: 590px; margin: 14px auto 0; display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||||
|
.audio-upload-button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); padding: 0 12px; color: var(--moss-dark); background: #fffefa; font-size: 12px; cursor: pointer; }
|
||||||
|
.audio-upload-button:hover { border-color: var(--moss); }
|
||||||
|
#audio-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }
|
||||||
|
.audio-upload-row > span { color: var(--muted); font-size: 10px; }
|
||||||
.queue-section, .archive-section { padding: 30px 0 34px; border-bottom: 1px solid var(--line); }
|
.queue-section, .archive-section { padding: 30px 0 34px; border-bottom: 1px solid var(--line); }
|
||||||
.section-heading { position: relative; display: flex; align-items: center; justify-content: center; gap: 18px; text-align: center; }
|
.section-heading { position: relative; display: flex; align-items: center; justify-content: center; gap: 18px; text-align: center; }
|
||||||
.section-heading .section-kicker { justify-content: center; }
|
.section-heading .section-kicker { justify-content: center; }
|
||||||
@@ -80,6 +85,7 @@ footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(saf
|
|||||||
.record-button { width: 46px; height: 46px; }
|
.record-button { width: 46px; height: 46px; }
|
||||||
.record-copy strong { font-size: 12px; }
|
.record-copy strong { font-size: 12px; }
|
||||||
.record-copy span { font-size: 10px; }
|
.record-copy span { font-size: 10px; }
|
||||||
|
.audio-upload-row { align-items: flex-start; flex-direction: column; }
|
||||||
h2 { font-size: 23px; }
|
h2 { font-size: 23px; }
|
||||||
.archive-date { width: 60px; font-size: 9px; }
|
.archive-date { width: 60px; font-size: 9px; }
|
||||||
footer { padding-left: 18px; padding-right: 18px; font-size: 8px; }
|
footer { padding-left: 18px; padding-right: 18px; font-size: 8px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user