From b2b0c0d579ceea968baf275bce1f8bd3fcd0820a Mon Sep 17 00:00:00 2001 From: Mute Date: Thu, 13 Aug 2026 10:31:46 +0000 Subject: [PATCH] rvsttd: model fetch mode, build from source, ~/.rvsttd convention --- rvsttd/Cargo.toml | 3 ++ rvsttd/build.rs | 7 +-- rvsttd/setup.sh | 95 ++++++++++++++++++++++++++++++++++++++++ rvsttd/src/main.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 rvsttd/setup.sh diff --git a/rvsttd/Cargo.toml b/rvsttd/Cargo.toml index 7928279..30c9dbc 100644 --- a/rvsttd/Cargo.toml +++ b/rvsttd/Cargo.toml @@ -10,6 +10,9 @@ path = "src/main.rs" [dependencies] anyhow = "1" cpal = "0.15" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ureq = "2" [build-dependencies] bindgen = "0.71" diff --git a/rvsttd/build.rs b/rvsttd/build.rs index e29bcd5..fca3987 100644 --- a/rvsttd/build.rs +++ b/rvsttd/build.rs @@ -1,9 +1,10 @@ use std::path::PathBuf; fn main() { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let lib_dir = manifest_dir.join("..").join("bench").join("moonshine-voice").join("lib"); - let include_dir = manifest_dir.join("..").join("bench").join("moonshine-voice").join("include"); + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let rvsttd_dir = PathBuf::from(&home).join(".rvsttd"); + let lib_dir = rvsttd_dir.join("lib"); + let include_dir = rvsttd_dir.join("include"); let header = include_dir.join("moonshine-c-api.h"); println!("cargo:rerun-if-changed={}", header.display()); diff --git a/rvsttd/setup.sh b/rvsttd/setup.sh new file mode 100644 index 0000000..6326df8 --- /dev/null +++ b/rvsttd/setup.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +RVSTTD_DIR="${HOME}/.rvsttd" +LIB_DIR="${RVSTTD_DIR}/lib" +INCLUDE_DIR="${RVSTTD_DIR}/include" +BUILD_DIR="${RVSTTD_DIR}/build" + +ARCH="linux-x86_64" + +# Fetch latest release tag from GitHub API +VERSION=$(curl -s https://api.github.com/repos/moonshine-ai/moonshine/releases/latest | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') +if [ -z "${VERSION}" ]; then + echo "ERROR: could not fetch latest release tag from GitHub API" + exit 1 +fi + +PREBUILT_URL="https://github.com/moonshine-ai/moonshine/releases/download/${VERSION}/moonshine-voice-${ARCH}.tar.gz" +SOURCE_URL="https://github.com/moonshine-ai/moonshine/archive/refs/tags/${VERSION}.tar.gz" + +echo "=== rvsttd setup ===" +echo "Latest Moonshine release: ${VERSION}" +echo "Target: ${RVSTTD_DIR}" +echo "" + +mkdir -p "${LIB_DIR}" "${INCLUDE_DIR}" "${BUILD_DIR}" + +# Step 1: Download prebuilt package (for libonnxruntime.so.1 + header) +PREBUILT_TGZ="${BUILD_DIR}/moonshine-voice-${ARCH}.tar.gz" +PREBUILT_EXTRACTED="${BUILD_DIR}/moonshine-voice-${ARCH}" + +if [ ! -f "${LIB_DIR}/libonnxruntime.so.1" ]; then + echo ">>> Downloading prebuilt package (for libonnxruntime.so.1)..." + curl -L -o "${PREBUILT_TGZ}" "${PREBUILT_URL}" + mkdir -p "${PREBUILT_EXTRACTED}" + tar xzf "${PREBUILT_TGZ}" -C "${PREBUILT_EXTRACTED}" --strip-components=1 + + # Copy ONNX Runtime (prebuilt is fine — it has no glibc issue) + cp "${PREBUILT_EXTRACTED}/lib/libonnxruntime.so.1" "${LIB_DIR}/" + echo " Installed libonnxruntime.so.1" + + # Copy the header (it's the same in source and prebuilt) + cp "${PREBUILT_EXTRACTED}/include/moonshine-c-api.h" "${INCLUDE_DIR}/" + echo " Installed moonshine-c-api.h" +else + echo ">>> libonnxruntime.so.1 already present, skipping prebuilt download" +fi + +# Step 2: Download source +SOURCE_TGZ="${BUILD_DIR}/moonshine-source.tar.gz" +SOURCE_DIR="${BUILD_DIR}/moonshine-source" + +if [ ! -d "${SOURCE_DIR}" ]; then + echo ">>> Downloading Moonshine source ${VERSION}..." + curl -L -o "${SOURCE_TGZ}" "${SOURCE_URL}" + mkdir -p "${SOURCE_DIR}" + tar xzf "${SOURCE_TGZ}" -C "${SOURCE_DIR}" --strip-components=1 +fi + +# Step 3: Build libmoonshine.so from source +CMAKE_BUILD="${BUILD_DIR}/cmake-build" + +if [ ! -f "${LIB_DIR}/libmoonshine.so" ]; then + echo ">>> Building libmoonshine.so from source..." + + # Point CMake at the prebuilt ONNX Runtime + ORT_LIB_DIR="${LIB_DIR}" + ORT_INCLUDE_DIR="${SOURCE_DIR}/core/third-party/onnxruntime/include" + + mkdir -p "${CMAKE_BUILD}" + cd "${CMAKE_BUILD}" + + cmake "${SOURCE_DIR}/core" \ + -DCMAKE_BUILD_TYPE=Release \ + -DONNXRUNTIME_LIB_PATH="${ORT_LIB_DIR}/libonnxruntime.so.1" \ + -DMOONSHINE_TTS_BUILD_ONNX=OFF + + make -j"$(nproc)" moonshine + + cp "${CMAKE_BUILD}/libmoonshine.so" "${LIB_DIR}/" + cp "${SOURCE_DIR}/core/moonshine-c-api.h" "${INCLUDE_DIR}/" + echo " Installed libmoonshine.so (built from source)" +else + echo ">>> libmoonshine.so already present, skipping build" +fi + +echo "" +echo "=== Setup complete ===" +echo "Library: ${LIB_DIR}/libmoonshine.so" +echo "Library: ${LIB_DIR}/libonnxruntime.so.1" +echo "Header: ${INCLUDE_DIR}/moonshine-c-api.h" +echo "" +echo "Next: cd rvsttd && cargo build --release" +echo "Then: ./target/release/rvsttd fetch # downloads the model" +echo "Then: ./target/release/rvsttd # starts the server" diff --git a/rvsttd/src/main.rs b/rvsttd/src/main.rs index 0202cf1..fffbbf0 100644 --- a/rvsttd/src/main.rs +++ b/rvsttd/src/main.rs @@ -1,10 +1,12 @@ use anyhow::{anyhow, Result}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{SampleFormat, SampleRate}; +use serde::Deserialize; use std::collections::HashSet; use std::ffi::CStr; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; @@ -353,11 +355,112 @@ fn start_cpal( Ok(stream) } +// ─── model fetch ────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct Manifest { + groups: Vec, +} + +#[derive(Deserialize)] +struct ManifestGroup { + #[allow(dead_code)] + base_url: String, + files: Vec, +} + +#[derive(Deserialize)] +struct ManifestFile { + name: String, + url: String, + size: Option, +} + +fn fetch_model(model_dir: &str) -> Result<()> { + let dest = PathBuf::from(model_dir); + log(&format!("Fetching medium-streaming-en model to {}", dest.display())); + + let lang = std::ffi::CString::new("en").unwrap(); + let opt_name = std::ffi::CString::new("model_arch").unwrap(); + let opt_value = std::ffi::CString::new("5").unwrap(); + let mut options = [moonshine_option_t { + name: opt_name.as_ptr(), + value: opt_value.as_ptr(), + }]; + + let mut json_ptr: *mut i8 = std::ptr::null_mut(); + let rc = unsafe { + moonshine_get_stt_dependencies( + lang.as_ptr(), + options.as_mut_ptr(), + options.len() as u64, + &mut json_ptr, + ) + }; + + if rc != 0 || json_ptr.is_null() { + return Err(anyhow!("moonshine_get_stt_dependencies failed: {}", err_str(rc))); + } + + let json_str = unsafe { CStr::from_ptr(json_ptr) } + .to_string_lossy() + .into_owned(); + unsafe { moonshine_free_buffer(json_ptr as *mut std::ffi::c_void) }; + + let manifest: Manifest = serde_json::from_str(&json_str)?; + + std::fs::create_dir_all(&dest)?; + + let mut total_files = 0; + let mut total_bytes: u64 = 0; + + for group in &manifest.groups { + for file in &group.files { + let dest_path = dest.join(&file.name); + if dest_path.exists() { + log(&format!(" SKIP {} (already exists)", file.name)); + continue; + } + + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } + + log(&format!(" GET {}", file.url)); + let response = ureq::get(&file.url).call()?; + + if let Some(expected) = file.size { + log(&format!(" {} bytes", expected)); + total_bytes += expected; + } + + let mut file_handle = std::fs::File::create(&dest_path)?; + std::io::copy(&mut response.into_reader(), &mut file_handle)?; + total_files += 1; + } + } + + log(&format!("Done: {} files, ~{} MB", total_files, total_bytes / (1024 * 1024))); + log(&format!("Model directory: {}", dest.display())); + Ok(()) +} + // ─── main ───────────────────────────────────────────────────────────────── fn main() -> Result<()> { let mut args = std::env::args().skip(1); - let mut model_dir = String::from("../bench/medium-streaming-en"); + + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let default_model = format!("{}/.rvsttd/model", home); + + let first = args.next(); + if first.as_deref() == Some("fetch") { + let model_dir = args.next().unwrap_or_else(|| default_model.clone()); + return fetch_model(&model_dir); + } + + let mut args = first.into_iter().chain(args); + let mut model_dir = default_model; while let Some(a) = args.next() { match a.as_str() { @@ -366,8 +469,10 @@ fn main() -> Result<()> { } "--help" | "-h" => { println!("Usage: rvsttd [--model-dir DIR]"); + println!(" rvsttd fetch [DIR]"); println!("Listens on TCP {}", BIND_ADDR); println!("Model: medium-streaming (Moonshine)"); + println!("'rvsttd fetch' downloads the English medium-streaming model"); return Ok(()); } _ => return Err(anyhow!("unknown arg: {}", a)),