fix(speech): generate TXT/JSON/MD locally from transcription result

Download buttons now use the already-completed transcription result
instead of re-sending the audio to the server for re-transcription.
This eliminates redundant server work and prevents download failures
when the second transcription attempt errors out.

Also wires up suppressNonSpeech and noSpeechThreshold options
through the API and server pipeline.
This commit is contained in:
truetype committed 2026-09-16 21:22:41 +02:00
1 parent a0bbd9a035
commit b15bd7794e
3 files changed
+125 -42

No files matched your search

+100 -38
View File
@@ -147,10 +147,16 @@ export default function SpeechTranscriber({
const threads = speechSettings?.threads || 4;
const backendPreference = speechSettings?.backendPreference || "auto";
const translate = speechSettings?.translate === true;
const suppressNonSpeech = speechSettings?.suppressNonSpeech !== false;
const noSpeechThreshold = speechSettings?.noSpeechThreshold ?? 0.30;
const showAdvanced = speechSettings?.showAdvanced === true;
const setLanguage = (val) => setSpeechSettings((prev) => ({ ...prev, language: val }));
const setThreads = (val) => setSpeechSettings((prev) => ({ ...prev, threads: val }));
const setBackendPreference = (val) => setSpeechSettings((prev) => ({ ...prev, backendPreference: val }));
const setSuppressNonSpeech = (val) => setSpeechSettings((prev) => ({ ...prev, suppressNonSpeech: val }));
const setNoSpeechThreshold = (val) => setSpeechSettings((prev) => ({ ...prev, noSpeechThreshold: val }));
const setShowAdvanced = (val) => setSpeechSettings((prev) => ({ ...prev, showAdvanced: val }));
const [audioBlob, setAudioBlob] = useState(null);
const [audioName, setAudioName] = useState("");
@@ -324,6 +330,8 @@ export default function SpeechTranscriber({
backendPreference,
translate,
filename: audioName || "audio.wav",
suppressNonSpeech,
noSpeechThreshold,
signal: controller.signal,
});
setResult(transcription);
@@ -352,54 +360,62 @@ export default function SpeechTranscriber({
setIsTranscribing(false);
};
const saveText = async () => {
if (!audioBlob) return;
await downloadTranscription("txt");
const saveText = () => {
if (!result) return;
downloadResultAsFile(result, "txt");
};
const saveJson = async () => {
if (!audioBlob) return;
await downloadTranscription("json");
const saveJson = () => {
if (!result) return;
downloadResultAsFile(result, "json");
};
const saveMarkdown = async () => {
if (!audioBlob) return;
await downloadTranscription("md");
const saveMarkdown = () => {
if (!result) return;
downloadResultAsFile(result, "md");
};
const downloadTranscription = async (format) => {
if (!audioBlob) return;
const downloadResultAsFile = (transcription, format) => {
try {
const params = new URLSearchParams();
if (selectedModel) params.set("model", selectedModel);
params.set("language", speechSettings?.language || "auto");
params.set("format", format);
params.set("threads", String(speechSettings?.threads || 4));
params.set("backendPreference", speechSettings?.backendPreference || "auto");
if (speechSettings?.translate) params.set("translate", "true");
params.set("filename", audioName || "audio.wav");
params.set("output_filename", `transcription-${Date.now()}`);
const baseName = (audioName || "transcription").replace(/\.[^.]+$/, "");
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
let fileContent = "";
let contentType = "text/plain;charset=utf-8";
let extension = "txt";
const res = await fetch(`/api/speech/transcribe-file?${params.toString()}`, {
method: "POST",
headers: { "Content-Type": "audio/wav" },
body: audioBlob,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `Download failed (HTTP ${res.status})`);
if (format === "json") {
fileContent = JSON.stringify({
text: transcription?.text || "",
model: transcription?.model || selectedModel || "",
modelName: transcription?.modelName || "",
language: transcription?.language || speechSettings?.language || "auto",
backend: transcription?.backend || "",
backendMode: transcription?.backendMode || "",
sourceFilename: transcription?.sourceFilename || audioName || "",
durationMs: transcription?.durationMs || 0,
raw: transcription?.raw || {},
createdAt: transcription?.createdAt || new Date().toISOString(),
}, null, 2);
contentType = "application/json;charset=utf-8";
extension = "json";
} else if (format === "md") {
const modelName = transcription?.modelName || transcription?.model || selectedModel || "unknown";
const lang = transcription?.language || speechSettings?.language || "auto";
const backend = transcription?.backend || "unknown";
const backendMode = transcription?.backendMode || "";
const source = transcription?.sourceFilename || audioName || "unknown";
const durMs = transcription?.durationMs || 0;
const durSec = durMs ? (durMs / 1000).toFixed(1) : "?";
fileContent = `# Trascrizione\n\n${transcription?.text || ""}\n\n---\n*Modello: ${modelName}\n*Lingua: ${lang}\n*Backend: ${backend} (${backendMode})\n*File sorgente: ${source}\n*Durata elaborazione: ${durSec}s\n*Generato il: ${new Date().toISOString()}`;
contentType = "text/markdown;charset=utf-8";
extension = "md";
} else {
fileContent = transcription?.text || "";
contentType = "text/plain;charset=utf-8";
extension = "txt";
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `transcription-${Date.now()}.${format}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
downloadBlob(`${baseName}-${stamp}.${extension}`, fileContent, contentType);
} catch (err) {
showAlert?.({ title: "Download Failed", message: err.message || String(err), danger: true });
}
@@ -437,6 +453,13 @@ export default function SpeechTranscriber({
</select>
</label>
{selectedModel && selectedModel.toLowerCase().includes("base") && (
<div style={{ marginTop: "6px", padding: "8px 10px", borderRadius: "6px", background: "var(--md-sys-color-error-container)", color: "var(--md-sys-color-on-error-container)", fontSize: "0.78rem", lineHeight: 1.4 }}>
⚠️ Il modello <strong>Base</strong> può non trascrivere correttamente audio compresso (es. Google Meet, Zoom) — potrebbe rilevare solo "[Musica]".
Si consiglia il modello <strong>Small Multilingual</strong> per audio di bassa qualità.
</div>
)}
<div className="speech-controls-row">
<label className="speech-label">
Backend
@@ -479,6 +502,45 @@ export default function SpeechTranscriber({
</label>
</div>
{/* Advanced settings toggle */}
<div style={{ marginTop: "10px", paddingLeft: "2px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "0.82rem", cursor: "pointer", color: "var(--md-sys-color-on-surface)" }}>
<input
type="checkbox"
checked={showAdvanced}
onChange={(event) => setShowAdvanced(event.target.checked)}
style={{ cursor: "pointer", width: "15px", height: "15px" }}
/>
<span>Advanced options</span>
</label>
</div>
{showAdvanced && (
<div style={{ marginTop: "12px", padding: "12px", borderRadius: "8px", background: "var(--md-sys-color-surface-variant)", display: "flex", flexDirection: "column", gap: "10px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "0.82rem", cursor: "pointer", color: "var(--md-sys-color-on-surface)" }}>
<input
type="checkbox"
checked={suppressNonSpeech}
onChange={(event) => setSuppressNonSpeech(event.target.checked)}
style={{ cursor: "pointer", width: "15px", height: "15px" }}
/>
<span>Suppress non-speech tokens (e.g. [Musica], [Applauso])</span>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: "4px", fontSize: "0.82rem", color: "var(--md-sys-color-on-surface)" }}>
<span>No-speech threshold: <strong>{noSpeechThreshold.toFixed(2)}</strong> (lower = more sensitive)</span>
<input
type="range"
min="0.10"
max="0.90"
step="0.05"
value={noSpeechThreshold}
onChange={(event) => setNoSpeechThreshold(Number(event.target.value))}
style={{ cursor: "pointer", width: "100%" }}
/>
</label>
</div>
)}
<div className="speech-button-row">
<button
className="m3-btn m3-btn-filled"
+2
View File
@@ -682,6 +682,8 @@ export async function transcribeSpeech(fileOrBlob, options = {}) {
if (options.threads) params.set("threads", String(options.threads));
if (options.backendPreference) params.set("backendPreference", options.backendPreference);
if (options.translate) params.set("translate", "true");
if (options.suppressNonSpeech !== undefined) params.set("suppressNonSpeech", options.suppressNonSpeech ? "true" : "false");
if (options.noSpeechThreshold !== undefined) params.set("noSpeechThreshold", String(options.noSpeechThreshold));
let contentType = "audio/wav";
if (fileOrBlob instanceof File && fileOrBlob.name) {
+23 -4
View File
@@ -2560,6 +2560,15 @@ function transcribeWavBuffer(buffer, options = {}) {
if (options.translate) {
args.push("-tr");
}
// Suppress non-speech tokens (e.g. [Musica], [Applauso]) that appear
// when the model misidentifies compressed/noisy audio as non-speech
if (options.suppressNonSpeech !== false) {
args.push("-sns");
}
// Lower no-speech threshold (default 0.60) so the model is less likely
// to label quiet or compressed speech as non-speech
const noSpeechThreshold = options.noSpeechThreshold || "0.30";
args.push("-nth", String(noSpeechThreshold));
const startedAt = Date.now();
console.log(" [speech] Starting:", backend.cli, args.join(" "));
@@ -6030,14 +6039,19 @@ const server = http.createServer(async (req, res) => {
const parsed = new URL(req.url, `http://${req.headers.host || "localhost"}`);
try {
const audio = await readBinaryBody(req);
const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, {
const suppressNonSpeech = parsed.searchParams.get("suppressNonSpeech");
const noSpeechThreshold = parsed.searchParams.get("noSpeechThreshold");
const transcribeOptions = {
model: parsed.searchParams.get("model") || speechSettings.model,
language: parsed.searchParams.get("language") || "auto",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording",
threads: parsed.searchParams.get("threads") || speechSettings.threads,
backendPreference: parsed.searchParams.get("backendPreference") || speechSettings.backendPreference,
translate: parsed.searchParams.get("translate") === "true",
}));
};
if (suppressNonSpeech !== null) transcribeOptions.suppressNonSpeech = suppressNonSpeech !== "false";
if (noSpeechThreshold !== null) transcribeOptions.noSpeechThreshold = noSpeechThreshold;
const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, transcribeOptions));
return json(res, 200, { ok: true, transcription: result });
} catch (err) {
speechError = err.message || String(err);
@@ -6052,14 +6066,19 @@ const server = http.createServer(async (req, res) => {
try {
const audio = await readBinaryBody(req);
const outputFormat = parsed.searchParams.get("format") || "txt";
const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, {
const suppressNonSpeech = parsed.searchParams.get("suppressNonSpeech");
const noSpeechThreshold = parsed.searchParams.get("noSpeechThreshold");
const transcribeOptions = {
model: parsed.searchParams.get("model") || speechSettings.model,
language: parsed.searchParams.get("language") || "auto",
filename: parsed.searchParams.get("filename") || req.headers["x-filename"] || "recording",
threads: parsed.searchParams.get("threads") || speechSettings.threads,
backendPreference: parsed.searchParams.get("backendPreference") || speechSettings.backendPreference,
translate: parsed.searchParams.get("translate") === "true",
}));
};
if (suppressNonSpeech !== null) transcribeOptions.suppressNonSpeech = suppressNonSpeech !== "false";
if (noSpeechThreshold !== null) transcribeOptions.noSpeechThreshold = noSpeechThreshold;
const result = await runExclusiveSpeechOperation(() => transcribeAudioBuffer(audio, transcribeOptions));
const filename = parsed.searchParams.get("output_filename") || `transcription-${Date.now()}`;
let fileContent = "";