From 098f9b1c75bc35c718ae0e38d5accffb706d44f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Thu, 9 Jul 2026 18:37:00 -0300 Subject: [PATCH 1/2] feat: add file transcription core (ffmpeg convert, whisper segments, srt/vtt export, history migration) --- src-tauri/src/engine.rs | 4 + src/engine/media.rs | 166 ++++++++++++++++++++++++++++++++++++ src/engine/mod.rs | 2 + src/engine/stt.rs | 121 +++++++++++++++++++++++++++ src/engine/transcript.rs | 176 +++++++++++++++++++++++++++++++++++++++ src/history.rs | 115 +++++++++++++++++++++++-- 6 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 src/engine/media.rs create mode 100644 src/engine/transcript.rs diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index cbb7e56..ed1cf87 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -615,6 +615,8 @@ impl Engine { provider: outcome.provider.clone(), model: outcome.model.clone(), language: cfg.stt.language.clone(), + source_file: None, + segments_json: None, }; let last_entry = match self.history.insert(&entry) { Ok(id) => { @@ -628,6 +630,8 @@ impl Engine { provider: entry.provider, model: entry.model, language: entry.language, + source_file: entry.source_file, + segments_json: entry.segments_json, word_count: pickscribe::history::count_words(&outcome.text), }) } diff --git a/src/engine/media.rs b/src/engine/media.rs new file mode 100644 index 0000000..443f27a --- /dev/null +++ b/src/engine/media.rs @@ -0,0 +1,166 @@ +use std::fs; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; + +pub const MEDIA_EXTENSIONS: &[&str] = &[ + "wav", "mp3", "m4a", "aac", "ogg", "opus", "flac", "wma", "mp4", "mkv", "mov", "webm", + "avi", "m4v", +]; + +pub fn resolve_ffmpeg() -> Result { + super::find_command("ffmpeg") + .context("ffmpeg not found in PATH — install ffmpeg to transcribe media files") +} + +pub fn convert_to_wav_16k_mono( + input: &Path, + output: &Path, + is_cancelled: impl Fn() -> bool, +) -> Result<()> { + let ffmpeg = resolve_ffmpeg()?; + let stderr_path = log_path(output); + let stderr_file = fs::File::create(&stderr_path) + .with_context(|| format!("creating ffmpeg log {}", stderr_path.display()))?; + let mut cmd = Command::new(ffmpeg); + cmd.args(["-nostdin", "-hide_banner", "-y", "-i"]) + .arg(input) + .args(["-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav"]) + .arg(output) + .stdout(Stdio::null()) + .stderr(Stdio::from(stderr_file)); + let mut child = match cmd.spawn().context("running ffmpeg") { + Ok(child) => child, + Err(err) => { + let _ = fs::remove_file(&stderr_path); + return Err(err); + } + }; + let status: ExitStatus; + loop { + if is_cancelled() { + let _ = child.kill(); + let _ = child.wait(); + let _ = fs::remove_file(output); + let _ = fs::remove_file(&stderr_path); + bail!("conversion cancelled"); + } + if let Some(exit_status) = child.try_wait()? { + status = exit_status; + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + if !status.success() { + let stderr = fs::read_to_string(&stderr_path).unwrap_or_default(); + let _ = fs::remove_file(output); + let _ = fs::remove_file(&stderr_path); + bail!("ffmpeg failed: {}", last_lines(&stderr, 15)); + } + + let _ = fs::remove_file(&stderr_path); + Ok(()) +} + +pub fn wav_duration_ms(path: &Path) -> Result { + let mut file = fs::File::open(path).with_context(|| format!("opening {}", path.display()))?; + let mut riff_header = [0u8; 12]; + file.read_exact(&mut riff_header) + .with_context(|| format!("reading WAV header from {}", path.display()))?; + if &riff_header[0..4] != b"RIFF" || &riff_header[8..12] != b"WAVE" { + bail!("unsupported WAV header"); + } + + let mut format = None; + let mut data_bytes = None; + loop { + let mut chunk_header = [0u8; 8]; + file.read_exact(&mut chunk_header) + .context("reading WAV chunk header")?; + let chunk_len = u32::from_le_bytes([ + chunk_header[4], + chunk_header[5], + chunk_header[6], + chunk_header[7], + ]) as u64; + + if &chunk_header[0..4] == b"fmt " { + if chunk_len < 16 { + bail!("unsupported WAV header"); + } + let mut fmt = [0u8; 16]; + file.read_exact(&mut fmt).context("reading WAV format chunk")?; + format = Some(( + u16::from_le_bytes([fmt[0], fmt[1]]), + u16::from_le_bytes([fmt[2], fmt[3]]), + u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]), + u16::from_le_bytes([fmt[14], fmt[15]]), + )); + skip_chunk(&mut file, chunk_len - 16)?; + } else { + if &chunk_header[0..4] == b"data" { + data_bytes = Some(chunk_len); + } + skip_chunk(&mut file, chunk_len)?; + } + + if format.is_some() && data_bytes.is_some() { + break; + } + } + + let (audio_format, channels, sample_rate, bits_per_sample) = format.context("missing WAV format chunk")?; + if audio_format != 1 || channels != 1 || sample_rate != 16_000 || bits_per_sample != 16 { + bail!("expected 16 kHz mono 16-bit PCM WAV"); + } + let data_bytes = data_bytes.context("missing WAV data chunk")?; + Ok((data_bytes as i64 * 1_000) / 32_000) +} + +fn log_path(output: &Path) -> PathBuf { + let mut path = output.as_os_str().to_os_string(); + path.push(".ffmpeg.log"); + PathBuf::from(path) +} + +fn last_lines(text: &str, count: usize) -> String { + let lines: Vec<_> = text.lines().rev().take(count).collect(); + lines.into_iter().rev().collect::>().join("\n") +} + +fn skip_chunk(file: &mut fs::File, chunk_len: u64) -> Result<()> { + let padded_len = chunk_len + chunk_len % 2; + file.seek(SeekFrom::Current(padded_len as i64))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::audio_segments::{SAMPLE_RATE_HZ, write_wav}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(name: &str) -> PathBuf { + let id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("pickscribe-{name}-{id}")) + } + + #[test] + fn wav_duration_reads_16khz_mono_pcm_data() -> Result<()> { + let dir = temp_dir("media-duration"); + let path = dir.join("audio.wav"); + write_wav(&path, &vec![0; SAMPLE_RATE_HZ as usize * 3 / 2])?; + + assert_eq!(wav_duration_ms(&path)?, 1_500); + + fs::remove_dir_all(dir).ok(); + Ok(()) + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 7b2c849..03aea27 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -2,11 +2,13 @@ pub mod audio_segments; pub mod cleanup; pub mod incremental; pub mod levels; +pub mod media; pub mod paste; pub mod recorder; pub mod segments; pub mod sounds; pub mod stt; +pub mod transcript; use std::path::PathBuf; diff --git a/src/engine/stt.rs b/src/engine/stt.rs index 79c8cec..3dcb047 100644 --- a/src/engine/stt.rs +++ b/src/engine/stt.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; use std::time::Duration; @@ -6,6 +7,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use crate::config::SttConfig; +use crate::engine::transcript::{FileSegment, parse_whisper_json}; pub struct WhisperSetup { pub program: PathBuf, @@ -156,6 +158,84 @@ pub fn transcribe_with_cancel( Ok(clean_transcript(&raw)) } +pub fn transcribe_file_with_cancel( + cfg: &SttConfig, + wav: &Path, + is_cancelled: impl Fn() -> bool, + on_progress: impl Fn(u8), +) -> Result> { + let setup = resolve_whisper(cfg)?; + let prefix = wav.with_extension("transcript"); + let stderr_path = prefix.with_extension("transcript.stderr.log"); + let stderr_file = fs::File::create(&stderr_path) + .with_context(|| format!("creating transcript log {}", stderr_path.display()))?; + let mut cmd = Command::new(&setup.program); + cmd.arg("--model") + .arg(&setup.model) + .arg("--file") + .arg(wav) + .arg("--output-json") + .arg("--output-file") + .arg(&prefix) + .arg("--print-progress") + .arg("--no-prints"); + let language = if cfg.language.is_empty() { + "auto" + } else { + cfg.language.as_str() + }; + cmd.arg("--language").arg(language); + cmd.stdout(Stdio::null()).stderr(Stdio::from(stderr_file)); + let mut child = match cmd.spawn().context("running whisper-cli") { + Ok(child) => child, + Err(err) => { + let _ = fs::remove_file(&stderr_path); + return Err(err); + } + }; + let mut last_progress = 0; + let status: ExitStatus; + loop { + if is_cancelled() { + let _ = child.kill(); + let _ = child.wait(); + let _ = fs::remove_file(prefix.with_extension("transcript.json")); + let _ = fs::remove_file(&stderr_path); + bail!("transcription cancelled"); + } + if let Some(progress) = read_log_tail(&stderr_path) + .lines() + .filter_map(parse_progress_percentage) + .max() + && progress > last_progress + { + last_progress = progress; + on_progress(progress); + } + if let Some(exit_status) = child.try_wait()? { + status = exit_status; + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + if !status.success() { + let stderr = fs::read_to_string(&stderr_path).unwrap_or_default(); + let _ = fs::remove_file(prefix.with_extension("transcript.json")); + let _ = fs::remove_file(&stderr_path); + bail!("whisper-cli failed: {}", stderr.trim()); + } + + let json_path = prefix.with_extension("transcript.json"); + let raw = fs::read_to_string(&json_path) + .with_context(|| format!("reading transcript {}", json_path.display())); + let _ = fs::remove_file(&json_path); + let _ = fs::remove_file(&stderr_path); + let segments = parse_whisper_json(&raw?)?; + on_progress(100); + Ok(segments) +} + /// Strip whisper timestamps and non-speech markers like [MUSIC], (laughs). pub fn clean_transcript(raw: &str) -> String { let mut lines = Vec::new(); @@ -193,3 +273,44 @@ fn shellexpand_home(path: &str) -> String { } path.to_string() } + +fn read_log_tail(path: &Path) -> String { + let Ok(mut file) = fs::File::open(path) else { + return String::new(); + }; + let start = file + .metadata() + .map(|metadata| metadata.len().saturating_sub(16 * 1024)) + .unwrap_or(0); + if file.seek(SeekFrom::Start(start)).is_err() { + return String::new(); + } + let mut tail = Vec::new(); + let _ = file.read_to_end(&mut tail); + String::from_utf8_lossy(&tail).into_owned() +} + +fn parse_progress_percentage(line: &str) -> Option { + let (_, rest) = line.split_once("progress =")?; + let percentage = rest.split_once('%')?.0.trim(); + percentage + .parse::() + .ok() + .filter(|percentage| *percentage <= 100) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_whisper_progress_percentages() { + assert_eq!( + parse_progress_percentage("whisper_print_progress: progress = 42%"), + Some(42) + ); + assert_eq!(parse_progress_percentage("progress = 100%"), Some(100)); + assert_eq!(parse_progress_percentage("progress = 101%"), None); + assert_eq!(parse_progress_percentage("no progress here"), None); + } +} diff --git a/src/engine/transcript.rs b/src/engine/transcript.rs new file mode 100644 index 0000000..06e576e --- /dev/null +++ b/src/engine/transcript.rs @@ -0,0 +1,176 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FileSegment { + pub start_ms: i64, + pub end_ms: i64, + pub text: String, +} + +#[derive(Deserialize)] +struct WhisperOutput { + transcription: Vec, +} + +#[derive(Deserialize)] +struct WhisperSegment { + offsets: WhisperOffsets, + text: String, +} + +#[derive(Deserialize)] +struct WhisperOffsets { + from: i64, + to: i64, +} + +pub fn parse_whisper_json(raw: &str) -> Result> { + let output: WhisperOutput = serde_json::from_str(raw).context("parsing whisper JSON")?; + Ok(output + .transcription + .into_iter() + .filter_map(|segment| { + let text = segment.text.trim(); + if text.is_empty() || is_non_speech_marker(text) { + return None; + } + Some(FileSegment { + start_ms: segment.offsets.from, + end_ms: segment.offsets.to, + text: text.to_string(), + }) + }) + .collect()) +} + +pub fn to_txt(segments: &[FileSegment]) -> String { + segments + .iter() + .map(|segment| segment.text.trim()) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" ") +} + +pub fn to_srt(segments: &[FileSegment]) -> String { + if segments.is_empty() { + return String::new(); + } + + let cues = segments + .iter() + .enumerate() + .map(|(index, segment)| { + format!( + "{}\n{} --> {}\n{}", + index + 1, + format_srt_timestamp(segment.start_ms), + format_srt_timestamp(segment.end_ms), + segment.text + ) + }) + .collect::>() + .join("\n\n"); + format!("{cues}\n") +} + +pub fn to_vtt(segments: &[FileSegment]) -> String { + let cues = segments + .iter() + .map(|segment| { + format!( + "{} --> {}\n{}", + format_vtt_timestamp(segment.start_ms), + format_vtt_timestamp(segment.end_ms), + segment.text + ) + }) + .collect::>() + .join("\n\n"); + if cues.is_empty() { + "WEBVTT\n\n".to_string() + } else { + format!("WEBVTT\n\n{cues}\n") + } +} + +fn is_non_speech_marker(text: &str) -> bool { + (text.starts_with('[') && text.ends_with(']')) + || (text.starts_with('(') && text.ends_with(')')) +} + +fn format_srt_timestamp(ms: i64) -> String { + format_timestamp(ms, ',') +} + +fn format_vtt_timestamp(ms: i64) -> String { + format_timestamp(ms, '.') +} + +fn format_timestamp(ms: i64, separator: char) -> String { + let ms = ms.max(0) as u64; + let hours = ms / 3_600_000; + let minutes = (ms % 3_600_000) / 60_000; + let seconds = (ms % 60_000) / 1_000; + let milliseconds = ms % 1_000; + format!("{hours:02}:{minutes:02}:{seconds:02}{separator}{milliseconds:03}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_whisper_json_and_drops_non_speech_markers() -> Result<()> { + let raw = r#"{ + "transcription": [ + {"offsets": {"from": 0, "to": 1420}, "text": " Hello there. "}, + {"offsets": {"from": 1420, "to": 2800}, "text": "[MUSIC]"}, + {"offsets": {"from": 2800, "to": 4310}, "text": "How are you?"} + ] + }"#; + + let segments = parse_whisper_json(raw)?; + + assert_eq!(segments.len(), 2); + assert_eq!(segments[0].start_ms, 0); + assert_eq!(segments[0].end_ms, 1_420); + assert_eq!(segments[0].text, "Hello there."); + assert_eq!(segments[1].text, "How are you?"); + assert_eq!(to_txt(&segments), "Hello there. How are you?"); + Ok(()) + } + + #[test] + fn writes_srt_and_vtt_with_hour_rollover() { + let segments = vec![ + FileSegment { + start_ms: 0, + end_ms: 1_234, + text: "First cue".into(), + }, + FileSegment { + start_ms: 3_661_001, + end_ms: 3_662_345, + text: "Past an hour".into(), + }, + ]; + + assert_eq!( + to_srt(&segments), + "1\n00:00:00,000 --> 00:00:01,234\nFirst cue\n\n2\n01:01:01,001 --> 01:01:02,345\nPast an hour\n" + ); + assert_eq!( + to_vtt(&segments), + "WEBVTT\n\n00:00:00.000 --> 00:00:01.234\nFirst cue\n\n01:01:01.001 --> 01:01:02.345\nPast an hour\n" + ); + } + + #[test] + fn writes_empty_exports() { + assert_eq!(to_txt(&[]), ""); + assert_eq!(to_srt(&[]), ""); + assert_eq!(to_vtt(&[]), "WEBVTT\n\n"); + } +} diff --git a/src/history.rs b/src/history.rs index 2fe7782..2971afa 100644 --- a/src/history.rs +++ b/src/history.rs @@ -17,6 +17,8 @@ pub struct HistoryEntry { pub provider: String, pub model: String, pub language: String, + pub source_file: Option, + pub segments_json: Option, pub word_count: i64, } @@ -28,6 +30,8 @@ pub struct NewEntry { pub provider: String, pub model: String, pub language: String, + pub source_file: Option, + pub segments_json: Option, } #[derive(Debug, Clone, Serialize)] @@ -82,11 +86,30 @@ impl HistoryDb { provider TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '', language TEXT NOT NULL DEFAULT '', + source_file TEXT, + segments_json TEXT, word_count INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_transcriptions_created ON transcriptions(created_at DESC);", )?; + let columns = { + let mut statement = conn.prepare("PRAGMA table_info(transcriptions)")?; + let rows = statement.query_map([], |row| row.get::<_, String>(1))?; + rows.collect::>>()? + }; + if !columns.iter().any(|column| column == "source_file") { + conn.execute( + "ALTER TABLE transcriptions ADD COLUMN source_file TEXT", + [], + )?; + } + if !columns.iter().any(|column| column == "segments_json") { + conn.execute( + "ALTER TABLE transcriptions ADD COLUMN segments_json TEXT", + [], + )?; + } Ok(Self { conn: Mutex::new(conn), }) @@ -107,8 +130,9 @@ impl HistoryDb { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO transcriptions - (created_at, duration_ms, raw_text, cleaned_text, provider, model, language, word_count) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + (created_at, duration_ms, raw_text, cleaned_text, provider, model, language, + source_file, segments_json, word_count) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ now_unix(), entry.duration_ms, @@ -117,6 +141,8 @@ impl HistoryDb { entry.provider, entry.model, entry.language, + entry.source_file, + entry.segments_json, words, ], )?; @@ -136,13 +162,15 @@ impl HistoryDb { provider: row.get(5)?, model: row.get(6)?, language: row.get(7)?, - word_count: row.get(8)?, + source_file: row.get(8)?, + segments_json: row.get(9)?, + word_count: row.get(10)?, }) }; if search.trim().is_empty() { let mut stmt = conn.prepare( "SELECT id, created_at, duration_ms, raw_text, cleaned_text, - provider, model, language, word_count + provider, model, language, source_file, segments_json, word_count FROM transcriptions ORDER BY created_at DESC LIMIT ?1 OFFSET ?2", )?; let rows = stmt.query_map(params![limit, offset], map_row)?; @@ -153,7 +181,7 @@ impl HistoryDb { let pattern = format!("%{}%", search.trim()); let mut stmt = conn.prepare( "SELECT id, created_at, duration_ms, raw_text, cleaned_text, - provider, model, language, word_count + provider, model, language, source_file, segments_json, word_count FROM transcriptions WHERE raw_text LIKE ?1 OR cleaned_text LIKE ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3", @@ -259,6 +287,8 @@ mod tests { provider: "none".into(), model: String::new(), language: "en".into(), + source_file: None, + segments_json: None, })?; db.insert(&NewEntry { duration_ms: 2_000, @@ -267,6 +297,8 @@ mod tests { provider: "ollama".into(), model: "qwen".into(), language: "en".into(), + source_file: None, + segments_json: None, })?; let metrics = db.metrics(70)?; @@ -300,6 +332,8 @@ mod tests { provider: "none".into(), model: String::new(), language: "en".into(), + source_file: None, + segments_json: None, })?; let metrics = db.metrics(0)?; @@ -311,4 +345,75 @@ mod tests { std::fs::remove_file(path).ok(); Ok(()) } + + #[test] + fn open_migrates_legacy_schema_and_round_trips_file_fields() -> Result<()> { + let path = temp_db_path("history-legacy-media"); + let conn = Connection::open(&path)?; + conn.execute_batch( + "CREATE TABLE transcriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at INTEGER NOT NULL, + duration_ms INTEGER NOT NULL DEFAULT 0, + raw_text TEXT NOT NULL, + cleaned_text TEXT, + provider TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '', + word_count INTEGER NOT NULL DEFAULT 0 + );", + )?; + drop(conn); + + let db = HistoryDb::open(&path)?; + let id = db.insert(&NewEntry { + duration_ms: 3_200, + raw_text: "legacy transcript".into(), + cleaned_text: None, + provider: "none".into(), + model: String::new(), + language: "en".into(), + source_file: Some("/home/me/recording.mp4".into()), + segments_json: Some("[{\"start_ms\":0}]".into()), + })?; + + let entries = db.list("", 10, 0)?; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, id); + assert_eq!(entries[0].source_file.as_deref(), Some("/home/me/recording.mp4")); + assert_eq!(entries[0].segments_json.as_deref(), Some("[{\"start_ms\":0}]")); + + drop(db); + std::fs::remove_file(path).ok(); + Ok(()) + } + + #[test] + fn fresh_database_round_trips_file_fields() -> Result<()> { + let path = temp_db_path("history-fresh-media"); + let db = HistoryDb::open(&path)?; + let id = db.insert(&NewEntry { + duration_ms: 8_000, + raw_text: "file transcript".into(), + cleaned_text: Some("clean file transcript".into()), + provider: "none".into(), + model: "ggml-small.bin".into(), + language: "pt".into(), + source_file: Some("meeting.webm".into()), + segments_json: Some("[{\"start_ms\":0,\"end_ms\":8000}]".into()), + })?; + + let entries = db.list("", 10, 0)?; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, id); + assert_eq!(entries[0].source_file.as_deref(), Some("meeting.webm")); + assert_eq!( + entries[0].segments_json.as_deref(), + Some("[{\"start_ms\":0,\"end_ms\":8000}]") + ); + + drop(db); + std::fs::remove_file(path).ok(); + Ok(()) + } } From 6c07135a2be1832922ed6605a1f6162a1d396430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Thu, 9 Jul 2026 18:38:06 -0300 Subject: [PATCH 2/2] docs: track file-transcription core in unreleased notes --- docs/releases/UNRELEASED.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index fe00e79..441b571 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -12,6 +12,10 @@ then reset this file. - Release workflow now strips bundled `libwayland` libraries from AppImage builds and re-signs the AppImage before collecting release assets. +- File-transcription core (PR 1 of 2 for #30): ffmpeg→WAV conversion, whisper + segment output with progress, TXT/SRT/VTT formatters, and an additive history + DB migration (`source_file`, `segments_json`). No user-facing surface yet; + the UI ships in PR 2. ## Validation