feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,48 @@
|
||||
#include "HybridWhisperSession.hpp"
|
||||
#include "androidUtil.h"
|
||||
#include <NitroModules/ArrayBuffer.hpp>
|
||||
|
||||
using namespace margelo::nitro::whispervoicetyping;
|
||||
|
||||
struct HybridWhisperSession::State_ {
|
||||
State_(const SessionOptions& options)
|
||||
: session_(options.modelPath, options.locale, options.prompt, options.shortAudioContext)
|
||||
{};
|
||||
|
||||
void addAudio(ArrayBuffer& data);
|
||||
|
||||
std::mutex mutex_;
|
||||
WhisperSession session_;
|
||||
};
|
||||
|
||||
void HybridWhisperSession::State_::addAudio(ArrayBuffer& data) {
|
||||
float* dataFloat = reinterpret_cast<float*>(data.data());
|
||||
size_t sizeFloats = data.size() / sizeof(float);
|
||||
|
||||
if (dataFloat == nullptr) {
|
||||
throw std::logic_error("Attempting to add audio from a source that has already been deleted.");
|
||||
}
|
||||
|
||||
LOGD("Add audio (size %zu)", sizeFloats);
|
||||
session_.addAudio(dataFloat, sizeFloats);
|
||||
}
|
||||
|
||||
HybridWhisperSession::HybridWhisperSession(
|
||||
const SessionOptions& options
|
||||
)
|
||||
: HybridObject(TAG),
|
||||
state_(std::make_shared<HybridWhisperSession::State_>(options))
|
||||
{ }
|
||||
|
||||
void HybridWhisperSession::pushAudio(const std::shared_ptr<ArrayBuffer>& audio) {
|
||||
std::lock_guard<std::mutex> lock { state_->mutex_ };
|
||||
|
||||
state_->addAudio(*audio);
|
||||
}
|
||||
|
||||
std::shared_ptr<Promise<std::string>> HybridWhisperSession::convertNext() {
|
||||
return Promise<std::string>::async([state = state_] () -> std::string {
|
||||
std::lock_guard<std::mutex> lock { state->mutex_ };
|
||||
return state->session_.transcribeNextChunk();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <mutex>
|
||||
#include <memory>
|
||||
|
||||
#include "HybridWhisperSessionSpec.hpp"
|
||||
#include "SessionOptions.hpp"
|
||||
#include "utils/WhisperSession.hpp"
|
||||
|
||||
namespace margelo::nitro::whispervoicetyping {
|
||||
class HybridWhisperSession : public HybridWhisperSessionSpec {
|
||||
public:
|
||||
explicit HybridWhisperSession(const SessionOptions& options);
|
||||
|
||||
void pushAudio(const std::shared_ptr<ArrayBuffer>& audio) override;
|
||||
std::shared_ptr<Promise<std::string>> convertNext() override;
|
||||
|
||||
private:
|
||||
struct State_;
|
||||
std::shared_ptr<State_> state_;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#include <memory>
|
||||
|
||||
#include "HybridWhisperVoiceTyping.hpp"
|
||||
#include "HybridWhisperSession.hpp"
|
||||
#include "findLongestSilence_test.hpp"
|
||||
#include "SingleThread_test.hpp"
|
||||
|
||||
|
||||
using namespace margelo::nitro::whispervoicetyping;
|
||||
|
||||
SessionPointer HybridWhisperVoiceTyping::openSession(const SessionOptions& options) {
|
||||
return std::make_shared<HybridWhisperSession> (options);
|
||||
}
|
||||
|
||||
std::shared_ptr<Promise<void>> HybridWhisperVoiceTyping::test() {
|
||||
return Promise<void>::async([=] () -> void {
|
||||
findLongestSilence_test();
|
||||
SingleThread_test();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
#include "HybridWhisperVoiceTypingSpec.hpp"
|
||||
|
||||
namespace margelo::nitro::whispervoicetyping {
|
||||
using SessionPointer = std::shared_ptr<HybridWhisperSessionSpec>;
|
||||
|
||||
class HybridWhisperVoiceTyping : public HybridWhisperVoiceTypingSpec {
|
||||
public:
|
||||
HybridWhisperVoiceTyping(): HybridObject(TAG) {};
|
||||
|
||||
SessionPointer openSession(const SessionOptions& options) override;
|
||||
|
||||
// Runs tests
|
||||
std::shared_ptr<Promise<void>> test() override;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "SingleThread.hpp"
|
||||
#include "androidUtil.h"
|
||||
|
||||
#include <condition_variable>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
|
||||
struct SingleThread::State_ {
|
||||
std::vector<std::function<void()>> tasks;
|
||||
|
||||
// mutex_ and cv_ are used as a signaling mechanism.
|
||||
// See, for example https://cplusplus.com/reference/condition_variable/condition_variable/
|
||||
// https://en.cppreference.com/w/cpp/thread/condition_variable.html
|
||||
std::condition_variable cv;
|
||||
std::mutex mutex;
|
||||
|
||||
bool cancelled = false;
|
||||
};
|
||||
|
||||
void threadLoop(std::shared_ptr<SingleThread::State_> state) {
|
||||
while (!state->cancelled) {
|
||||
// Create a lock that can be used by state->cv (see https://stackoverflow.com/a/13102893
|
||||
// for why).
|
||||
std::unique_lock lock {state->mutex};
|
||||
|
||||
// Run all tasks **first**, to handle the case where the threadLoop starts after the first
|
||||
// task has been posted.
|
||||
for (auto& task : state->tasks) {
|
||||
task();
|
||||
}
|
||||
state->tasks.clear();
|
||||
|
||||
state->cv.wait(lock);
|
||||
}
|
||||
}
|
||||
|
||||
SingleThread::SingleThread()
|
||||
: state_ { std::make_shared<State_>() },
|
||||
thread_ { threadLoop, state_ } {
|
||||
}
|
||||
|
||||
SingleThread::~SingleThread() {
|
||||
{
|
||||
std::lock_guard lock { state_->mutex };
|
||||
state_->cancelled = true;
|
||||
}
|
||||
|
||||
state_->cv.notify_all();
|
||||
thread_.join();
|
||||
}
|
||||
|
||||
void SingleThread::post(std::function<void()> task) {
|
||||
{
|
||||
std::lock_guard lock { state_->mutex };
|
||||
state_->tasks.push_back(std::move(task));
|
||||
}
|
||||
state_->cv.notify_all();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <thread>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
|
||||
// Reserves a single thread. Similar to Java/Android's "Handler"
|
||||
class SingleThread {
|
||||
public:
|
||||
SingleThread();
|
||||
// Waits for all pending tasks to complete before destruction
|
||||
~SingleThread();
|
||||
|
||||
// Run the `task` on the thread and return a future that will resolve to the result
|
||||
template <typename T>
|
||||
std::future<T> run(std::function<T()> task) {
|
||||
auto promise = std::make_shared<std::promise<T>>();
|
||||
post([promise = promise, task = std::move(task)] {
|
||||
try {
|
||||
T result = task();
|
||||
promise->set_value(result);
|
||||
} catch (...) {
|
||||
// Use current_exception to create an exception pointer.
|
||||
// See https://en.cppreference.com/w/cpp/thread/promise/set_exception
|
||||
promise->set_exception(std::current_exception());
|
||||
}
|
||||
});
|
||||
return promise->get_future();
|
||||
}
|
||||
|
||||
// Explicit definition of run<void> to work around templating issues:
|
||||
std::future<void> run(std::function<void()> task) {
|
||||
auto promise = std::make_shared<std::promise<void>>();
|
||||
post([promise = promise, task = std::move(task)] {
|
||||
try {
|
||||
task();
|
||||
promise->set_value();
|
||||
} catch (...) {
|
||||
promise->set_exception(std::current_exception());
|
||||
}
|
||||
});
|
||||
return promise->get_future();
|
||||
}
|
||||
|
||||
// Run the `task` on the thread and wait for it to complete
|
||||
template <typename T>
|
||||
T runAndWait(std::function<T()> task) {
|
||||
return run(task).get();
|
||||
}
|
||||
|
||||
struct State_;
|
||||
private:
|
||||
// Asynchronously runs the given `task` on the thread
|
||||
void post(std::function<void()> task);
|
||||
|
||||
private:
|
||||
std::shared_ptr<State_> state_;
|
||||
|
||||
std::thread thread_;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "SingleThread_test.hpp"
|
||||
#include "SingleThread.hpp"
|
||||
#include "testing.hpp"
|
||||
#include "androidUtil.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sstream>
|
||||
#include <cmath>
|
||||
#include <random>
|
||||
|
||||
void shouldConstructAndDestruct();
|
||||
void shouldReturnSingleValue();
|
||||
void shouldReturnMultipleAsync();
|
||||
void shouldForwardExceptions();
|
||||
|
||||
void SingleThread_test() {
|
||||
LOGD("SingleThread: Starting tests...");
|
||||
shouldConstructAndDestruct();
|
||||
shouldReturnSingleValue();
|
||||
shouldReturnMultipleAsync();
|
||||
shouldForwardExceptions();
|
||||
}
|
||||
|
||||
void shouldConstructAndDestruct() {
|
||||
{
|
||||
SingleThread thread {};
|
||||
LOGD("SingleThread: Constructed!");
|
||||
}
|
||||
LOGD("SingleThread: Destructed!");
|
||||
logTestPass("shouldConstructAndDestruct");
|
||||
}
|
||||
|
||||
void shouldReturnSingleValue() {
|
||||
SingleThread thread {};
|
||||
|
||||
LOGD("SingleThread: Posting a value and waiting!");
|
||||
|
||||
assertEqual(thread.runAndWait<int>([] {
|
||||
LOGD("SingleThread: Inside thread!");
|
||||
return 3;
|
||||
}), 3, "should support returning a value from a lambda");
|
||||
LOGD("SingleThread: Posted a value and waited!");
|
||||
|
||||
logTestPass("shouldReturnSingleValue");
|
||||
}
|
||||
|
||||
void shouldReturnMultipleAsync() {
|
||||
SingleThread thread {};
|
||||
|
||||
auto future1 = thread.run<int>([] {
|
||||
return 1;
|
||||
});
|
||||
auto future2 = thread.run<int>([] {
|
||||
return 2;
|
||||
});
|
||||
auto future3 = thread.run<int>([] {
|
||||
return 3;
|
||||
});
|
||||
|
||||
assertEqual(future1.get(), 1, "getting future1");
|
||||
assertEqual(future3.get(), 3, "getting future3");
|
||||
assertEqual(future2.get(), 2, "getting future2");
|
||||
|
||||
logTestPass("shouldReturnMultipleAsync");
|
||||
}
|
||||
|
||||
void shouldForwardExceptions() {
|
||||
SingleThread thread {};
|
||||
|
||||
bool caught = false;
|
||||
try {
|
||||
thread.runAndWait<void>([] {
|
||||
throw std::runtime_error("testing...");
|
||||
});
|
||||
} catch (std::runtime_error ex) {
|
||||
caught = true;
|
||||
}
|
||||
|
||||
assertEqual(caught, true, "should have caught the exception");
|
||||
logTestPass("shouldForwardExceptions");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void SingleThread_test();
|
||||
@@ -0,0 +1,249 @@
|
||||
#include "WhisperSession.hpp"
|
||||
|
||||
#include <utility>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include "whisper.h"
|
||||
#include "findLongestSilence.hpp"
|
||||
#include "androidUtil.h"
|
||||
|
||||
WhisperSession::WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext)
|
||||
: lang_ {std::move(lang)}, prompt_ {std::move(prompt)}, shortAudioContext_ {shortAudioContext} {
|
||||
whisper_context_params contextParams = whisper_context_default_params();
|
||||
|
||||
// Lifetime(pModelPath): Whisper.cpp creates a copy of pModelPath and stores it in a std::string.
|
||||
// whisper_init_from_file_with_params doesn't seem to otherwise save pModelPath. As such, it's
|
||||
// safe to pass a pointer to a std::string's representation:
|
||||
const char *pModelPath = modelPath.c_str();
|
||||
pContext_ = whisper_init_from_file_with_params(pModelPath, contextParams);
|
||||
|
||||
if (pContext_ == nullptr) {
|
||||
throw std::runtime_error("Unable to initialize the Whisper context.");
|
||||
}
|
||||
}
|
||||
|
||||
WhisperSession::~WhisperSession() {
|
||||
if (pContext_ != nullptr) {
|
||||
whisper_free(pContext_);
|
||||
}
|
||||
}
|
||||
|
||||
whisper_full_params
|
||||
WhisperSession::buildWhisperParams_() {
|
||||
whisper_full_params params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
|
||||
// WHISPER_SAMPLING_BEAM_SEARCH is an alternative to greedy:
|
||||
// params.beam_search = { .beam_size = 2 };
|
||||
params.print_realtime = false;
|
||||
// Disable timestamps: They make creating custom Whisper models more difficult:
|
||||
params.print_timestamps = false;
|
||||
params.no_timestamps = true;
|
||||
|
||||
params.print_progress = false;
|
||||
params.translate = false;
|
||||
params.offset_ms = 0;
|
||||
params.single_segment = true;
|
||||
// Avoid non-speech tokens (e.g. "(crackle)"). For now, this is disabled because it seems to
|
||||
// cause increased hallucinations (e.g. repeated "Thank you"s).
|
||||
// params.suppress_nst = true;
|
||||
params.temperature = 0; // Initial randomness
|
||||
// There's also a temperature_inc variable, which is used when decoding fails (Whisper increases
|
||||
// the temperature by temperature_inc and retries).
|
||||
|
||||
// Following the whisper streaming example in setting prompt_tokens to nullptr
|
||||
// when using VAD (Voice Activity Detection)
|
||||
params.initial_prompt = prompt_.c_str();
|
||||
params.prompt_tokens = nullptr;
|
||||
params.prompt_n_tokens = 0;
|
||||
params.audio_ctx = 0;
|
||||
|
||||
// Lifetime: lifetime(params) < lifetime(lang_) = lifetime(this).
|
||||
params.language = lang_.c_str();
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
std::string
|
||||
WhisperSession::transcribe_(const std::vector<float>& audio, size_t transcribeCount) {
|
||||
// Whisper won't transcribe anything shorter than 1s.
|
||||
int minTranscribeLength = WHISPER_SAMPLE_RATE; // 1s
|
||||
if (transcribeCount < minTranscribeLength) {
|
||||
return "";
|
||||
}
|
||||
|
||||
float seconds = static_cast<float>(transcribeCount) / WHISPER_SAMPLE_RATE;
|
||||
if (seconds > 30.0f) {
|
||||
LOGW("Warning: Audio is longer than 30 seconds. Not all audio will be transcribed");
|
||||
}
|
||||
|
||||
whisper_full_params params = buildWhisperParams_();
|
||||
|
||||
// If supported by the model, allow shortening the transcription. This can significantly
|
||||
// improve performance, but requires a fine-tuned model.
|
||||
// See https://github.com/futo-org/whisper-acft
|
||||
if (this->shortAudioContext_) {
|
||||
// audio_ctx: 1500 every 30 seconds (50 units in one second).
|
||||
// See https://github.com/futo-org/whisper-acft/issues/6
|
||||
float padding = 64.0f;
|
||||
params.audio_ctx = static_cast<int>(seconds * (1500.0f / 30.0f) + padding);
|
||||
|
||||
if (params.audio_ctx > 1500) {
|
||||
params.audio_ctx = 1500;
|
||||
}
|
||||
}
|
||||
whisper_reset_timings(pContext_);
|
||||
|
||||
transcribeCount = std::min(audio.size(), transcribeCount);
|
||||
|
||||
if (whisper_full(pContext_, params, audio.data(), transcribeCount) != 0) {
|
||||
throw std::runtime_error("Failed to run Whisper (non-zero exit status).");
|
||||
} else {
|
||||
whisper_print_timings(pContext_);
|
||||
}
|
||||
|
||||
// Tokens to be used as a prompt for the next run of Whisper
|
||||
unsigned int segmentCount = whisper_full_n_segments(pContext_);
|
||||
|
||||
// Build the results
|
||||
std::stringstream results;
|
||||
for (int i = 0; i < segmentCount; i++) {
|
||||
results << " " << whisper_full_get_segment_text(pContext_, i);
|
||||
}
|
||||
|
||||
std::string result = results.str();
|
||||
LOGD("Transcribed: %s (audio len %.2f)", result.c_str(), audio.size() / (float) WHISPER_SAMPLE_RATE);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string
|
||||
WhisperSession::splitAndTranscribeBefore_(int transcribeUpTo, int trimTo) {
|
||||
std::string result = transcribe_(audioBuffer_, transcribeUpTo);
|
||||
|
||||
// Trim
|
||||
LOGI("Trim to %.2f s, transcribe to %.2f s", (float) trimTo / WHISPER_SAMPLE_RATE, (float) transcribeUpTo / WHISPER_SAMPLE_RATE);
|
||||
audioBuffer_ = std::vector(audioBuffer_.begin() + trimTo, audioBuffer_.end());
|
||||
return result;
|
||||
}
|
||||
|
||||
bool WhisperSession::isBufferSilent_() {
|
||||
int toleranceSamples = WHISPER_SAMPLE_RATE / 5; // 0.2s
|
||||
auto silence = findLongestSilence(
|
||||
audioBuffer_,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = WHISPER_SAMPLE_RATE,
|
||||
.minSilenceLengthSeconds = 0.0f,
|
||||
.maximumSilenceStartSamples = toleranceSamples, // 0.5s
|
||||
.returnFirstMatch = true
|
||||
}
|
||||
);
|
||||
return silence.end >= audioBuffer_.size() - toleranceSamples;
|
||||
}
|
||||
|
||||
std::string
|
||||
WhisperSession::transcribeNextChunk() {
|
||||
std::stringstream result;
|
||||
|
||||
// Handles a silence detected between (splitStart, splitEnd).
|
||||
auto splitAndProcess = [&] (int splitStart, int splitEnd) {
|
||||
int tolerance = WHISPER_SAMPLE_RATE / 8; // 0.125s
|
||||
bool isCompletelySilent = splitStart < tolerance && splitEnd > audioBuffer_.size() - tolerance;
|
||||
LOGD("WhisperSession: Found silence range from %.2f -> %.2f", splitStart / (float) WHISPER_SAMPLE_RATE, splitEnd / (float) WHISPER_SAMPLE_RATE);
|
||||
|
||||
if (isCompletelySilent) {
|
||||
audioBuffer_.clear();
|
||||
return false;
|
||||
} else if (splitEnd > tolerance) { // Anything to transcribe?
|
||||
// Include some of the silence between the start and the end. Excluding it
|
||||
// seems to make Whisper more likely to omit trailing punctuation.
|
||||
int maximumSilentSamples = WHISPER_SAMPLE_RATE;
|
||||
int silentSamplesToAdd = std::min(maximumSilentSamples, (splitEnd - splitStart) / 2);
|
||||
splitStart += silentSamplesToAdd;
|
||||
|
||||
result << splitAndTranscribeBefore_(splitStart, splitEnd) << "\n\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
int maximumSamples = WHISPER_SAMPLE_RATE * 25;
|
||||
|
||||
// Handle paragraph breaks indicated by long pauses
|
||||
while (audioBuffer_.size() > WHISPER_SAMPLE_RATE * 3) {
|
||||
LOGD("WhisperSession: Checking for a longer pauses.");
|
||||
// Allow brief pauses to create new paragraphs:
|
||||
float minSilenceSeconds = 1.5f;
|
||||
auto splitPoint = findLongestSilence(
|
||||
audioBuffer_,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = WHISPER_SAMPLE_RATE,
|
||||
.minSilenceLengthSeconds = minSilenceSeconds,
|
||||
.maximumSilenceStartSamples = maximumSamples,
|
||||
.returnFirstMatch = true
|
||||
}
|
||||
);
|
||||
if (!splitPoint.isValid) {
|
||||
break;
|
||||
}
|
||||
if (!splitAndProcess(splitPoint.start, splitPoint.end)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no long pauses, force a paragraph break somewhere
|
||||
if (audioBuffer_.size() >= maximumSamples) {
|
||||
LOGD("WhisperSession: Allowing shorter pauses to break.");
|
||||
float minSilenceSeconds = 0.3f;
|
||||
auto silenceRange = findLongestSilence(
|
||||
audioBuffer_,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = WHISPER_SAMPLE_RATE,
|
||||
.minSilenceLengthSeconds = minSilenceSeconds,
|
||||
.maximumSilenceStartSamples = maximumSamples,
|
||||
.returnFirstMatch = false
|
||||
}
|
||||
);
|
||||
|
||||
// In this case, the audio is long enough that it needs to be split somewhere. If there's
|
||||
// no suitable pause available, default to splitting in the middle.
|
||||
int halfBufferSize = audioBuffer_.size() / 2;
|
||||
int splitStart = silenceRange.isValid ? silenceRange.start : halfBufferSize;
|
||||
int splitEnd = silenceRange.isValid ? silenceRange.end : halfBufferSize;
|
||||
splitAndProcess(splitStart, splitEnd);
|
||||
}
|
||||
|
||||
return result.str();
|
||||
}
|
||||
|
||||
|
||||
void WhisperSession::addAudio(float* data, size_t count) {
|
||||
// See https://en.cppreference.com/w/cpp/algorithm/copy_n.html,
|
||||
// and the suggestions at https://stackoverflow.com/q/34552783.
|
||||
audioBuffer_.reserve(count);
|
||||
std::copy_n(data, count, std::back_inserter(audioBuffer_));
|
||||
}
|
||||
|
||||
std::string WhisperSession::transcribeAll() {
|
||||
if (isBufferSilent_()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
std::stringstream result;
|
||||
|
||||
std::string transcribed;
|
||||
auto update_transcribed = [&] {
|
||||
transcribed = transcribeNextChunk();
|
||||
return !transcribed.empty();
|
||||
};
|
||||
while (update_transcribed()) {
|
||||
result << transcribed << "\n\n";
|
||||
}
|
||||
|
||||
// Transcribe content considered by transcribeNextChunk as partial:
|
||||
if (!isBufferSilent_()) {
|
||||
result << transcribe_(audioBuffer_, audioBuffer_.size());
|
||||
}
|
||||
audioBuffer_.clear();
|
||||
|
||||
return result.str();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "whisper.h"
|
||||
|
||||
class WhisperSession {
|
||||
public:
|
||||
WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext);
|
||||
~WhisperSession();
|
||||
// Adds to the buffer
|
||||
void addAudio(float* audio, size_t count);
|
||||
// Returns the next finalized slice of audio (if any) and updates the preview.
|
||||
std::string transcribeNextChunk();
|
||||
// Transcribes all buffered audio data that hasn't been finalized yet
|
||||
std::string transcribeAll();
|
||||
|
||||
private:
|
||||
whisper_full_params buildWhisperParams_();
|
||||
std::string transcribe_(const std::vector<float>& audio, size_t samplesToTranscribe);
|
||||
std::string splitAndTranscribeBefore_(int transcribeUpTo, int trimTo);
|
||||
|
||||
bool isBufferSilent_();
|
||||
|
||||
whisper_context *pContext_;
|
||||
const std::string lang_;
|
||||
const std::string prompt_;
|
||||
const bool shortAudioContext_;
|
||||
|
||||
std::vector<float> audioBuffer_;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
// For now, logging methods are no-ops on iOS
|
||||
#define LOGW(...) void;
|
||||
#define LOGI(...) void;
|
||||
#define LOGD(...) void;
|
||||
|
||||
|
||||
#else
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
// Use macros for these rather than functions. Functions generate a "may be unsafe"
|
||||
// warning because the compiler can't check that the first argument is a string
|
||||
// literal.
|
||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, "Whisper::JNI", __VA_ARGS__);
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "Whisper::JNI", __VA_ARGS__);
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "Whisper::JNI", __VA_ARGS__);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "findLongestSilence.hpp"
|
||||
#include "androidUtil.h"
|
||||
|
||||
static void highpass(std::vector<float>& data, int sampleRate) {
|
||||
// Highpass filter. See https://en.wikipedia.org/wiki/High-pass_filter and
|
||||
// the example in whisper.cpp/streaming.
|
||||
float highpassCutoffHz = 60.0f;
|
||||
float RC = 1.0f / (2 * 3.1416f * highpassCutoffHz);
|
||||
float timePerSample = 1.0f / sampleRate;
|
||||
float alpha = RC / (RC + timePerSample);
|
||||
|
||||
float lastInput = data[0];
|
||||
for (int i = 1; i < data.size(); i++) {
|
||||
float currentInput = data[i];
|
||||
data[i] = alpha * data[i - 1] + alpha * (currentInput - lastInput);
|
||||
lastInput = currentInput;
|
||||
}
|
||||
}
|
||||
|
||||
SilenceRange findLongestSilence(
|
||||
const std::vector<float>& audioData,
|
||||
LongestSilenceOptions options
|
||||
) {
|
||||
// Options variables
|
||||
int sampleRate = options.sampleRate;
|
||||
int maxSilencePosition = options.maximumSilenceStartSamples;
|
||||
float minSilenceLengthSeconds = options.minSilenceLengthSeconds;
|
||||
bool returnFirstMatch = options.returnFirstMatch;
|
||||
|
||||
// State
|
||||
int bestCandidateLength = 0;
|
||||
int bestCandidateStart = -1;
|
||||
int bestCandidateEnd = -1;
|
||||
int currentCandidateStart = -1;
|
||||
|
||||
std::vector<float> processedAudio { audioData };
|
||||
highpass(processedAudio, sampleRate);
|
||||
|
||||
// Break into windows of size `windowSize`:
|
||||
int windowSize = 256;
|
||||
int windowsPerSecond = sampleRate / windowSize;
|
||||
int quietWindows = 0; // Number of relatively quiet windows encountered
|
||||
|
||||
// Finishes the current candidate for longest silence
|
||||
auto finalizeCandidate = [&] (int currentOffset) {
|
||||
bool hasCandidate = currentCandidateStart >= 0;
|
||||
if (!hasCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
int currentCandidateLength = currentOffset - currentCandidateStart;
|
||||
if (currentCandidateLength > bestCandidateLength && currentCandidateStart <= maxSilencePosition) {
|
||||
bestCandidateLength = currentCandidateLength;
|
||||
bestCandidateStart = currentCandidateStart;
|
||||
bestCandidateEnd = currentOffset;
|
||||
LOGD("New best candidate with length %d", currentCandidateLength);
|
||||
}
|
||||
|
||||
currentCandidateStart = -1;
|
||||
};
|
||||
|
||||
int windowOffset;
|
||||
for (windowOffset = 0; windowOffset < processedAudio.size() && windowOffset <= maxSilencePosition; windowOffset += windowSize) {
|
||||
int rollingAverageSize = 24;
|
||||
float threshold = static_cast<float>(rollingAverageSize) / 80.0f;
|
||||
|
||||
// Count the number of samples that (when averaged with the nearby samples)
|
||||
// are below some threshold value.
|
||||
float absSum = 0;
|
||||
int silentSamples = 0;
|
||||
for (int i = windowOffset; i < windowOffset + windowSize && i < processedAudio.size(); i++) {
|
||||
absSum += abs(processedAudio[i]);
|
||||
|
||||
bool isSumComplete = i - rollingAverageSize >= windowOffset;
|
||||
if (isSumComplete) {
|
||||
absSum -= abs(processedAudio[i - rollingAverageSize]);
|
||||
|
||||
if (absSum < threshold) {
|
||||
silentSamples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The window should be considered "quiet" if enough samples were below the threshold.
|
||||
// Don't require all of them to be to allow clicks and pops.
|
||||
if (silentSamples >= windowSize * 3 / 4) {
|
||||
quietWindows ++;
|
||||
} else {
|
||||
quietWindows = 0;
|
||||
}
|
||||
|
||||
int minQuietWindows = static_cast<int>(windowsPerSecond * minSilenceLengthSeconds);
|
||||
if (quietWindows >= minQuietWindows && currentCandidateStart == -1) { // Found silence
|
||||
// Ignore the first window, which probably contains some of the start of the audio
|
||||
// and the most recent window, which came after windowOffset.
|
||||
int windowsToIgnore = 2;
|
||||
int estimatedQuietSamples = std::max(0, quietWindows - windowsToIgnore) * windowSize;
|
||||
currentCandidateStart = windowOffset - estimatedQuietSamples;
|
||||
} else if (quietWindows == 0) { // Silence ended
|
||||
// Ended a candidate. Is it better than the best?
|
||||
finalizeCandidate(windowOffset);
|
||||
|
||||
// Search for more candidates or return now?
|
||||
if (returnFirstMatch && bestCandidateLength > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalizeCandidate(windowOffset);
|
||||
|
||||
// Return the best candidate.
|
||||
if (bestCandidateLength == 0) {
|
||||
return { .isValid = false, .start = 0, .end = 0 };
|
||||
} else {
|
||||
return {
|
||||
.isValid=true,
|
||||
.start=bestCandidateStart,
|
||||
.end=bestCandidateEnd
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
#include <tuple>
|
||||
|
||||
struct SilenceRange {
|
||||
bool isValid;
|
||||
int start;
|
||||
int end;
|
||||
};
|
||||
|
||||
struct LongestSilenceOptions {
|
||||
int sampleRate;
|
||||
|
||||
// Minimum length of a silence range (e.g. 3.0 seconds)
|
||||
float minSilenceLengthSeconds;
|
||||
|
||||
// The maximum position for a silence range to start (ignore
|
||||
// all silences after this position).
|
||||
int maximumSilenceStartSamples;
|
||||
|
||||
// Return the first silence satisfying the conditions instead of
|
||||
// the longest.
|
||||
bool returnFirstMatch;
|
||||
};
|
||||
|
||||
SilenceRange findLongestSilence(
|
||||
const std::vector<float>& audioData,
|
||||
LongestSilenceOptions options
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#include "findLongestSilence_test.hpp"
|
||||
#include "findLongestSilence.hpp"
|
||||
#include "androidUtil.h"
|
||||
#include "testing.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sstream>
|
||||
#include <cmath>
|
||||
#include <random>
|
||||
|
||||
static void testTones();
|
||||
static void testToneWithPause();
|
||||
static void testSilence();
|
||||
static void testNoise();
|
||||
|
||||
struct GeneratedAudio {
|
||||
std::vector<float> data;
|
||||
int sampleRate;
|
||||
int sampleCount;
|
||||
};
|
||||
|
||||
using AudioGenerator = std::function<const float(float)>;
|
||||
static GeneratedAudio makeAudio(const AudioGenerator& generator, int sampleRate, float duration);
|
||||
static void expectNoSilence(const GeneratedAudio& audio, const std::string& testLabel);
|
||||
static void expectSilenceBetween(const GeneratedAudio& audio, float startTimeSeconds, float stopTimeSeconds, const std::string& testLabel);
|
||||
|
||||
|
||||
void findLongestSilence_test() {
|
||||
testTones();
|
||||
testToneWithPause();
|
||||
testSilence();
|
||||
testNoise();
|
||||
}
|
||||
|
||||
|
||||
static void testTones() {
|
||||
for (int frequency = 440; frequency < 1600; frequency += 300) {
|
||||
std::stringstream messageBuilder;
|
||||
messageBuilder << "Should not find silence in tone with frequency " << frequency << " HZ.";
|
||||
|
||||
auto audioTone = makeAudio([frequency](float t) {
|
||||
// Also set the amplitude to 0.2f (to more closely match mic input).
|
||||
return std::sin(t * static_cast<float>(frequency)) * 0.2f;
|
||||
}, 15000, 10.0f);
|
||||
|
||||
expectNoSilence(audioTone, messageBuilder.str());
|
||||
}
|
||||
|
||||
auto lowFrequencyTone = makeAudio([](float t) {
|
||||
return std::sin(t * 8) * 0.3f;
|
||||
}, 15000, 10.0f);
|
||||
expectSilenceBetween(lowFrequencyTone, 0.0f, 10.0f, "Should find silence in a very low-frequency tone");
|
||||
}
|
||||
|
||||
static void testToneWithPause() {
|
||||
auto audioToneWithPause = makeAudio([](float t) {
|
||||
if (t < 5.0f || t > 6.0f) {
|
||||
return std::sin(t * 880);
|
||||
} else {
|
||||
return 0.0f;
|
||||
}
|
||||
}, 15000, 11.0f);
|
||||
expectSilenceBetween(audioToneWithPause, 5.0f, 6.0f, "Should find silence when completely silent in a region");
|
||||
|
||||
auto audioToneWithTwoPauses = makeAudio([](float t) {
|
||||
if (t < 1.0f || (t > 8.0f && t < 10.0f)) {
|
||||
return 0.0f;
|
||||
} else {
|
||||
return std::sin(t * 880);
|
||||
}
|
||||
}, 15000, 20.0f);
|
||||
expectSilenceBetween(audioToneWithPause, 5.0f, 6.0f, "Should find silence when completely silent in a region");
|
||||
}
|
||||
|
||||
static void testSilence() {
|
||||
auto silence = makeAudio([](float t) {
|
||||
return 0.0f;
|
||||
}, 16000, 10.0f);
|
||||
expectSilenceBetween(silence, 0.0f, 10.0f, "Should find silence in a completely silent signal");
|
||||
}
|
||||
|
||||
static void testNoise() {
|
||||
std::minstd_rand randomness {2};
|
||||
std::uniform_real_distribution noiseGenerator {-1.0, 1.0};
|
||||
auto quietNoise = makeAudio([&](float t) {
|
||||
return noiseGenerator(randomness) * 0.02f;
|
||||
}, 16000, 5.0f);
|
||||
expectSilenceBetween(quietNoise, 0.0f, 5.0f, "Should find silence in a tone with low-amplitude noise");
|
||||
}
|
||||
|
||||
|
||||
static GeneratedAudio makeAudio(const AudioGenerator& generator, int sampleRate, float duration) {
|
||||
std::vector<float> result { };
|
||||
|
||||
int numSamples = static_cast<int>(static_cast<float>(sampleRate) * duration);
|
||||
for (int i = 0; i < numSamples; i++) {
|
||||
float time = static_cast<float>(i) / static_cast<float>(sampleRate);
|
||||
result.push_back(generator(time));
|
||||
}
|
||||
|
||||
return {
|
||||
.data=result,
|
||||
.sampleRate=sampleRate,
|
||||
.sampleCount=numSamples,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static float samplesToSeconds(int samples, int sampleRate) {
|
||||
return static_cast<float>(samples) / static_cast<float>(sampleRate);
|
||||
}
|
||||
|
||||
static void expectNoSilence(const GeneratedAudio& audio, const std::string& testLabel) {
|
||||
auto silence = findLongestSilence(
|
||||
audio.data,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = audio.sampleRate,
|
||||
.minSilenceLengthSeconds = 0.02f,
|
||||
.maximumSilenceStartSamples = audio.sampleCount,
|
||||
.returnFirstMatch = false,
|
||||
}
|
||||
);
|
||||
if (silence.isValid) {
|
||||
std::stringstream errorBuilder;
|
||||
float startSeconds = samplesToSeconds(silence.start, audio.sampleRate);
|
||||
float stopSeconds = samplesToSeconds(silence.end, audio.sampleRate);
|
||||
errorBuilder << "Error: Found silence between " << startSeconds << "s and " << stopSeconds << "s";
|
||||
errorBuilder << ": " << testLabel;
|
||||
fail(errorBuilder.str());
|
||||
}
|
||||
|
||||
logTestPass(testLabel);
|
||||
}
|
||||
|
||||
static void expectSilenceBetween(const GeneratedAudio& audio, float startTimeSeconds, float stopTimeSeconds, const std::string& testLabel) {
|
||||
auto silenceResult = findLongestSilence(
|
||||
audio.data,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = audio.sampleRate,
|
||||
.minSilenceLengthSeconds = 0.02f,
|
||||
.maximumSilenceStartSamples = audio.sampleCount,
|
||||
.returnFirstMatch = false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!silenceResult.isValid) {
|
||||
fail("Error: No silence found: " + testLabel);
|
||||
}
|
||||
|
||||
auto checkEndpoint = [&] (int actualValueSamples, float expectedValueSeconds, const std::string& description) {
|
||||
float actualValueSeconds = samplesToSeconds(actualValueSamples, audio.sampleRate);
|
||||
float tolerance = 0.1f; // 100ms
|
||||
if (std::abs(expectedValueSeconds - actualValueSeconds) > tolerance) {
|
||||
std::stringstream messageBuilder;
|
||||
messageBuilder << "Error: Silence " << description << " mismatch: ";
|
||||
messageBuilder << "got " << actualValueSeconds << "s expected " << expectedValueSeconds << "s. ";
|
||||
messageBuilder << testLabel;
|
||||
fail(messageBuilder.str());
|
||||
}
|
||||
};
|
||||
|
||||
checkEndpoint(silenceResult.start, startTimeSeconds, "start time");
|
||||
checkEndpoint(silenceResult.end, stopTimeSeconds, "stop time");
|
||||
|
||||
logTestPass(testLabel);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void findLongestSilence_test();
|
||||
@@ -0,0 +1,9 @@
|
||||
#include "testing.hpp"
|
||||
|
||||
void fail(const std::string& message) {
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
void logTestPass(const std::string& message) {
|
||||
LOGI("Test PASS: %s", message.c_str());
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
#include "androidUtil.h"
|
||||
|
||||
// Test-related utilities
|
||||
|
||||
void fail(const std::string& message);
|
||||
|
||||
void logTestPass(const std::string& message);
|
||||
|
||||
template<typename T>
|
||||
void assertEqual(const T& a, const T& b, const std::string& message) {
|
||||
if (a != b) {
|
||||
std::stringstream fullMessage;
|
||||
fullMessage << "Not equal:" << a << " != " << b << " (" << message << ")";
|
||||
fail(fullMessage.str());
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户