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

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -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());
}
}