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:
1 parent
a0bbd9a035
commit
b15bd7794e
3 files changed
+125
-42
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in new issue
Block a user