Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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),
})
}
Expand Down
166 changes: 166 additions & 0 deletions src/engine/media.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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<i64> {
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::<Vec<_>>().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(())
}
}
2 changes: 2 additions & 0 deletions src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
121 changes: 121 additions & 0 deletions src/engine/stt.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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};

use crate::config::SttConfig;
use crate::engine::transcript::{FileSegment, parse_whisper_json};

pub struct WhisperSetup {
pub program: PathBuf,
Expand Down Expand Up @@ -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<Vec<FileSegment>> {
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();
Expand Down Expand Up @@ -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<u8> {
let (_, rest) = line.split_once("progress =")?;
let percentage = rest.split_once('%')?.0.trim();
percentage
.parse::<u8>()
.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);
}
}
Loading