Enhance audio processing capabilities: add FFmpeg for audio transcoding, update database schema, and improve UI for audio file uploads

This commit is contained in:
2026-09-26 21:51:50 +02:00
parent eb83b16b54
commit 0f7d5e950c
8 changed files with 135 additions and 20 deletions
+4 -1
View File
@@ -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
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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; }