feat: Add session logging, input gain, and context-aware prompts

Major features:
- Session logging system with detailed segment tracking (audio files, metadata, latencies)
- Input gain control (0.5x-5.0x amplifier) with soft clipping
- Context-aware Whisper prompts using recent transcriptions
- Comprehensive segment metadata (RMS, peak, duration, timestamps)
- API latency measurements for Whisper and Claude
- Audio hash-based duplicate detection
- Hallucination filtering with detailed logging

Changes:
- Add SessionLogger class for structured session data export
- Apply input gain before VAD and denoising (not just raw input)
- Enhanced Pipeline with segment tracking and error logging
- New UI control for input gain amplifier
- Sessions saved to sessions/ directory with transcripts/ export
- Improved Whisper prompt in config.json (French instructions)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
StillHammer 2025-11-28 12:17:21 +08:00
parent 9163e082da
commit 3ec2a8beca
58 changed files with 1805 additions and 13 deletions

View File

@ -108,6 +108,7 @@ set(SOURCES_UI
src/ui/TranslationUI.cpp
# Utils
src/utils/Config.cpp
src/utils/SessionLogger.cpp
# Core
src/core/Pipeline.cpp
)

View File

@ -10,7 +10,7 @@
"model": "gpt-4o-mini-transcribe",
"language": "zh",
"temperature": 0.0,
"prompt": "The following is a conversation in Mandarin Chinese about business, family, and daily life. Common names: Tingting, Alexis.",
"prompt": "Transcription d'une reunion en chinois mandarin. Plusieurs interlocuteurs parlent. Ne transcris PAS: musique, silence, bruits de fond, applaudissements. Ne genere JAMAIS ces phrases: 谢谢观看, 感谢收看, 订阅, Thank you for watching, Subscribe, 再见. Si l'audio est inaudible ou juste du bruit, renvoie une chaine vide. Noms possibles: Tingting, Alexis.",
"stream": false,
"response_format": "text"
},

BIN
secondvoice_temp.opus Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,20 @@
{
"id": 1,
"chinese": "那是一个竹子做的。",
"french": "C'est fait en bambou.",
"audio": {
"duration_seconds": 3.210,
"rms_level": 0.0235,
"peak_level": 0.1645,
"filename": "001.opus"
},
"timestamps": {
"start": "2025-11-24T09:17:33.048",
"end": "2025-11-24T09:17:37.371"
},
"processing": {
"whisper_latency_ms": 2096.9,
"claude_latency_ms": 2187.6,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 2,
"chinese": "那是一个竹子做的。",
"french": "C'est fait en bambou.",
"audio": {
"duration_seconds": 9.620,
"rms_level": 0.0759,
"peak_level": 0.4828,
"filename": "002.opus"
},
"timestamps": {
"start": "2025-11-24T09:17:51.700",
"end": "2025-11-24T09:17:54.330"
},
"processing": {
"whisper_latency_ms": 1386.4,
"claude_latency_ms": 1126.4,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 3,
"chinese": "那么我们也去不花生。声音能够量就那么大。",
"french": "Alors nous n'allons pas non plus aux cacahuètes. Le volume sonore ne peut être que si grand.",
"audio": {
"duration_seconds": 5.040,
"rms_level": 0.0465,
"peak_level": 0.2801,
"filename": "003.opus"
},
"timestamps": {
"start": "2025-11-24T09:17:56.893",
"end": "2025-11-24T09:17:59.404"
},
"processing": {
"whisper_latency_ms": 867.1,
"claude_latency_ms": 1576.8,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 4,
"chinese": "那是一个竹子做的。",
"french": "C'est fait en bambou.",
"audio": {
"duration_seconds": 2.740,
"rms_level": 0.0246,
"peak_level": 0.1515,
"filename": "004.opus"
},
"timestamps": {
"start": "2025-11-24T09:17:59.609",
"end": "2025-11-24T09:18:01.702"
},
"processing": {
"whisper_latency_ms": 856.9,
"claude_latency_ms": 1203.2,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 5,
"chinese": "那是一个竹子做的。",
"french": "C'est fait en bambou.",
"audio": {
"duration_seconds": 0.830,
"rms_level": 0.0157,
"peak_level": 0.1333,
"filename": "005.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:01.752",
"end": "2025-11-24T09:18:03.862"
},
"processing": {
"whisper_latency_ms": 867.9,
"claude_latency_ms": 1229.1,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 6,
"chinese": "那么我们也去不花生。声音能够量就那么大。",
"french": "Alors nous n'allons pas non plus aux cacahuètes. Le volume sonore peut être juste à ce niveau.",
"audio": {
"duration_seconds": 0.730,
"rms_level": 0.0117,
"peak_level": 0.1107,
"filename": "006.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:03.529",
"end": "2025-11-24T09:18:06.412"
},
"processing": {
"whisper_latency_ms": 814.0,
"claude_latency_ms": 1723.0,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 7,
"chinese": "那是一个竹子做的。",
"french": "C'est fait en bambou.",
"audio": {
"duration_seconds": 4.180,
"rms_level": 0.0276,
"peak_level": 0.2319,
"filename": "007.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:09.004",
"end": "2025-11-24T09:18:11.442"
},
"processing": {
"whisper_latency_ms": 1173.2,
"claude_latency_ms": 1214.7,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 8,
"chinese": "那是一个比较古朴的。",
"french": "C'est quelque chose de plutôt ancien et traditionnel.",
"audio": {
"duration_seconds": 4.410,
"rms_level": 0.0215,
"peak_level": 0.1613,
"filename": "008.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:13.697",
"end": "2025-11-24T09:18:15.990"
},
"processing": {
"whisper_latency_ms": 1059.4,
"claude_latency_ms": 1179.2,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 9,
"chinese": "那么我们也去不花生。声音能够量就那么大。那是一个竹子做的。那是一个比较古朴的。",
"french": "Alors nous n'allons pas non plus aux arachides. Le volume sonore peut être juste comme ça. C'est fait en bambou. C'est assez ancien.",
"audio": {
"duration_seconds": 0.840,
"rms_level": 0.0093,
"peak_level": 0.0592,
"filename": "009.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:14.617",
"end": "2025-11-24T09:18:18.713"
},
"processing": {
"whisper_latency_ms": 1087.0,
"claude_latency_ms": 1622.6,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 10,
"chinese": "这些人都在干啥呢?",
"french": "Que font ces gens ?",
"audio": {
"duration_seconds": 2.250,
"rms_level": 0.0212,
"peak_level": 0.1146,
"filename": "010.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:19.138",
"end": "2025-11-24T09:18:21.211"
},
"processing": {
"whisper_latency_ms": 776.3,
"claude_latency_ms": 1265.9,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 11,
"chinese": "那是一个比较古朴的。",
"french": "C'est quelque chose de plutôt ancien et traditionnel.",
"audio": {
"duration_seconds": 1.410,
"rms_level": 0.0132,
"peak_level": 0.0778,
"filename": "011.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:20.551",
"end": "2025-11-24T09:18:23.207"
},
"processing": {
"whisper_latency_ms": 749.3,
"claude_latency_ms": 1225.4,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 12,
"chinese": "我们今天要讲的。",
"french": "Nous allons parler aujourd'hui.",
"audio": {
"duration_seconds": 2.490,
"rms_level": 0.0119,
"peak_level": 0.0850,
"filename": "012.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:23.302",
"end": "2025-11-24T09:18:26.456"
},
"processing": {
"whisper_latency_ms": 1099.0,
"claude_latency_ms": 2022.9,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 13,
"chinese": "这些人都在干啥呢?",
"french": "Que font tous ces gens ?",
"audio": {
"duration_seconds": 1.460,
"rms_level": 0.0124,
"peak_level": 0.0814,
"filename": "013.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:24.812",
"end": "2025-11-24T09:18:29.900"
},
"processing": {
"whisper_latency_ms": 1528.2,
"claude_latency_ms": 1887.3,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 14,
"chinese": "这些人都在干啥呢?",
"french": "Qu'est-ce que ces gens sont en train de faire ?",
"audio": {
"duration_seconds": 2.120,
"rms_level": 0.0138,
"peak_level": 0.1027,
"filename": "014.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:28.246",
"end": "2025-11-24T09:18:32.130"
},
"processing": {
"whisper_latency_ms": 959.4,
"claude_latency_ms": 1242.9,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 15,
"chinese": "假装会。",
"french": "Faire semblant.",
"audio": {
"duration_seconds": 1.760,
"rms_level": 0.0177,
"peak_level": 0.1595,
"filename": "015.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:30.523",
"end": "2025-11-24T09:18:34.165"
},
"processing": {
"whisper_latency_ms": 913.2,
"claude_latency_ms": 1098.1,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 16,
"chinese": "然后在这里边。",
"french": "Ensuite à l'intérieur.",
"audio": {
"duration_seconds": 1.710,
"rms_level": 0.0174,
"peak_level": 0.1387,
"filename": "016.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:32.295",
"end": "2025-11-24T09:18:36.587"
},
"processing": {
"whisper_latency_ms": 951.6,
"claude_latency_ms": 1446.2,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 17,
"chinese": "这些人都在干啥呢?",
"french": "Que font ces gens ?",
"audio": {
"duration_seconds": 0.800,
"rms_level": 0.0068,
"peak_level": 0.0555,
"filename": "017.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:33.323",
"end": "2025-11-24T09:18:38.725"
},
"processing": {
"whisper_latency_ms": 747.7,
"claude_latency_ms": 1379.6,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 18,
"chinese": "外面的样子,实际上没有。",
"french": "L'apparence extérieure n'existe en réalité pas.",
"audio": {
"duration_seconds": 3.720,
"rms_level": 0.0202,
"peak_level": 0.1103,
"filename": "018.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:38.208",
"end": "2025-11-24T09:18:41.532"
},
"processing": {
"whisper_latency_ms": 1292.5,
"claude_latency_ms": 1465.7,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 19,
"chinese": "然后在这里边。",
"french": "Ensuite à l'intérieur.",
"audio": {
"duration_seconds": 0.830,
"rms_level": 0.0085,
"peak_level": 0.0587,
"filename": "019.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:39.724",
"end": "2025-11-24T09:18:43.611"
},
"processing": {
"whisper_latency_ms": 914.8,
"claude_latency_ms": 1152.8,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 20,
"chinese": "哪些外面的情况比较烂。",
"french": "Quelles sont les situations extérieures qui sont plutôt mauvaises.",
"audio": {
"duration_seconds": 2.940,
"rms_level": 0.0150,
"peak_level": 0.0884,
"filename": "020.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:42.920",
"end": "2025-11-24T09:18:45.934"
},
"processing": {
"whisper_latency_ms": 966.4,
"claude_latency_ms": 1321.6,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 21,
"chinese": "然后来看一下。",
"french": "Ensuite, viens voir.",
"audio": {
"duration_seconds": 2.110,
"rms_level": 0.0259,
"peak_level": 0.1535,
"filename": "021.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:45.067",
"end": "2025-11-24T09:18:47.886"
},
"processing": {
"whisper_latency_ms": 794.9,
"claude_latency_ms": 1126.7,
"was_filtered": false
}
}

View File

@ -0,0 +1,20 @@
{
"id": 22,
"chinese": "然后来看一下。",
"french": "Ensuite, viens jeter un coup d'œil.",
"audio": {
"duration_seconds": 3.430,
"rms_level": 0.0162,
"peak_level": 0.1952,
"filename": "022.opus"
},
"timestamps": {
"start": "2025-11-24T09:18:48.616",
"end": "2025-11-24T09:18:51.376"
},
"processing": {
"whisper_latency_ms": 1079.8,
"claude_latency_ms": 1639.0,
"was_filtered": false
}
}

View File

@ -43,18 +43,32 @@ int AudioCapture::audioCallback(const void* input, void* output,
const float* in = static_cast<const float*>(input);
unsigned long sample_count = frame_count * self->channels_;
// === APPLY INPUT GAIN ===
// Get gain value and apply to input samples
float gain = self->input_gain_.load(std::memory_order_relaxed);
std::vector<float> amplified_samples(sample_count);
for (unsigned long i = 0; i < sample_count; ++i) {
// Apply gain with soft clipping to avoid harsh distortion
float sample = in[i] * gain;
// Soft clip to [-1, 1] range using tanh-like curve
if (sample > 1.0f) sample = 1.0f - 1.0f / (1.0f + sample - 1.0f);
else if (sample < -1.0f) sample = -1.0f + 1.0f / (1.0f - sample - 1.0f);
amplified_samples[i] = sample;
}
const float* amplified_in = amplified_samples.data();
// === REAL-TIME DENOISING ===
// Process audio through RNNoise in real-time for meter display
std::vector<float> denoised_samples;
if (self->noise_reducer_ && self->noise_reducer_->isEnabled()) {
denoised_samples = self->noise_reducer_->processRealtime(in, sample_count);
denoised_samples = self->noise_reducer_->processRealtime(amplified_in, sample_count);
}
// Calculate RMS and Peak from RAW audio (for VAD detection)
// Calculate RMS and Peak from AMPLIFIED audio (for VAD detection)
float raw_sum_squared = 0.0f;
float raw_max_amp = 0.0f;
for (unsigned long i = 0; i < sample_count; ++i) {
float sample = in[i];
float sample = amplified_in[i];
raw_sum_squared += sample * sample;
if (std::abs(sample) > raw_max_amp) {
raw_max_amp = std::abs(sample);
@ -158,9 +172,9 @@ int AudioCapture::audioCallback(const void* input, void* output,
self->speech_buffer_.insert(self->speech_buffer_.end(),
denoised_samples.begin(), denoised_samples.end());
} else {
// Fallback to raw if denoising disabled
// Fallback to amplified if denoising disabled
for (unsigned long i = 0; i < sample_count; ++i) {
self->speech_buffer_.push_back(in[i]);
self->speech_buffer_.push_back(amplified_in[i]);
}
}
self->speech_samples_count_ += sample_count;
@ -194,13 +208,13 @@ int AudioCapture::audioCallback(const void* input, void* output,
// If we were speaking and now have enough silence, flush
if (self->speech_buffer_.size() > 0) {
// Add trailing silence (denoised)
// Add trailing silence (denoised or amplified)
if (!denoised_samples.empty()) {
self->speech_buffer_.insert(self->speech_buffer_.end(),
denoised_samples.begin(), denoised_samples.end());
} else {
for (unsigned long i = 0; i < sample_count; ++i) {
self->speech_buffer_.push_back(in[i]);
self->speech_buffer_.push_back(amplified_in[i]);
}
}

View File

@ -33,6 +33,10 @@ public:
vad_rms_threshold_ = rms_threshold;
vad_peak_threshold_ = peak_threshold;
}
// Input gain (amplifier) - can be adjusted in real-time from UI
void setInputGain(float gain) { input_gain_ = gain; }
float getInputGain() const { return input_gain_; }
void setSilenceDuration(int ms) { silence_duration_ms_ = ms; }
void setMinSpeechDuration(int ms) { min_speech_duration_ms_ = ms; }
void setMaxSpeechDuration(int ms) { max_speech_duration_ms_ = ms; }
@ -90,6 +94,9 @@ private:
std::atomic<float> current_rms_{0.0f};
std::atomic<float> current_peak_{0.0f};
// Input gain (amplifier) - 1.0 = no change, >1.0 = amplify, <1.0 = attenuate
std::atomic<float> input_gain_{1.0f};
// Noise reduction
std::unique_ptr<NoiseReducer> noise_reducer_;
};

View File

@ -5,6 +5,7 @@
#include "../api/ClaudeClient.h"
#include "../ui/TranslationUI.h"
#include "../utils/Config.h"
#include "../utils/SessionLogger.h"
#include <iostream>
#include <iomanip>
#include <sstream>
@ -61,6 +62,10 @@ bool Pipeline::initialize() {
// Create recordings directory if it doesn't exist
std::filesystem::create_directories(config.getRecordingConfig().output_directory);
// Initialize session logger
session_logger_ = std::make_unique<SessionLogger>();
session_logger_->setModels(config.getWhisperConfig().model, config.getClaudeConfig().model);
return true;
}
@ -71,6 +76,15 @@ bool Pipeline::start() {
running_ = true;
// Start session logging
if (session_logger_) {
session_logger_->startSession();
session_logger_->setVadSettings(
ui_ ? ui_->getVadThreshold() : 0.02f,
ui_ ? ui_->getVadPeakThreshold() : 0.08f
);
}
// Start background threads
audio_thread_ = std::thread(&Pipeline::audioThread, this);
processing_thread_ = std::thread(&Pipeline::processingThread, this);
@ -126,6 +140,11 @@ void Pipeline::stop() {
transcript_ss << "transcripts/transcript_" << timestamp.str() << ".txt";
ui_->exportTranscript(transcript_ss.str());
}
// End session logging
if (session_logger_) {
session_logger_->endSession();
}
}
void Pipeline::audioThread() {
@ -137,11 +156,25 @@ void Pipeline::audioThread() {
// Add to full recording
full_recording_->addSamples(audio_data);
// Push to processing queue
// Calculate RMS and peak levels for metadata
float sum_squared = 0.0f;
float max_amp = 0.0f;
for (const float& sample : audio_data) {
sum_squared += sample * sample;
if (std::abs(sample) > max_amp) {
max_amp = std::abs(sample);
}
}
float rms = audio_data.empty() ? 0.0f : std::sqrt(sum_squared / audio_data.size());
// Push to processing queue with metadata
AudioChunk chunk;
chunk.data = audio_data;
chunk.sample_rate = config.getAudioConfig().sample_rate;
chunk.channels = config.getAudioConfig().channels;
chunk.rms_level = rms;
chunk.peak_level = max_amp;
chunk.timestamp = std::chrono::system_clock::now();
audio_queue_.push(std::move(chunk));
});
@ -170,7 +203,36 @@ void Pipeline::processingThread() {
float duration = static_cast<float>(chunk.data.size()) / (chunk.sample_rate * chunk.channels);
std::cout << "[Processing] Speech segment: " << duration << "s" << std::endl;
// Transcribe with Whisper
// Prepare segment data for logging
SegmentData segment;
segment.id = session_logger_ ? session_logger_->getNextSegmentId() : 0;
segment.start_time = chunk.timestamp;
segment.duration_seconds = duration;
segment.rms_level = chunk.rms_level;
segment.peak_level = chunk.peak_level;
segment.was_filtered = false;
// Save audio for this segment (also calculates hashes for duplicate detection)
if (session_logger_) {
segment.audio_filename = session_logger_->saveSegmentAudio(
segment.id, chunk.data, chunk.sample_rate, chunk.channels,
segment.audio_hashes);
}
// Build dynamic prompt with recent context
std::string whisper_prompt = config.getWhisperConfig().prompt;
if (session_logger_) {
auto recent = session_logger_->getRecentTranscriptions(3);
if (!recent.empty()) {
whisper_prompt += "\n\nRecent context: ";
for (const auto& t : recent) {
whisper_prompt += t + " ";
}
}
}
// Transcribe with Whisper (measure latency)
auto whisper_start = std::chrono::steady_clock::now();
auto whisper_result = whisper_client_->transcribe(
chunk.data,
chunk.sample_rate,
@ -178,12 +240,18 @@ void Pipeline::processingThread() {
config.getWhisperConfig().model,
config.getWhisperConfig().language,
config.getWhisperConfig().temperature,
config.getWhisperConfig().prompt,
whisper_prompt,
config.getWhisperConfig().response_format
);
auto whisper_end = std::chrono::steady_clock::now();
segment.whisper_latency_ms = std::chrono::duration<float, std::milli>(whisper_end - whisper_start).count();
if (!whisper_result.has_value()) {
std::cerr << "Whisper transcription failed" << std::endl;
segment.was_filtered = true;
segment.filter_reason = "whisper_api_error";
segment.end_time = std::chrono::system_clock::now();
if (session_logger_) session_logger_->logSegment(segment);
continue;
}
@ -195,6 +263,10 @@ void Pipeline::processingThread() {
size_t end = text.find_last_not_of(" \t\n\r");
if (start == std::string::npos) {
std::cout << "[Skip] Empty transcription" << std::endl;
segment.was_filtered = true;
segment.filter_reason = "empty";
segment.end_time = std::chrono::system_clock::now();
if (session_logger_) session_logger_->logSegment(segment);
continue;
}
text = text.substr(start, end - start + 1);
@ -267,6 +339,11 @@ void Pipeline::processingThread() {
if (is_garbage) {
std::cout << "[Skip] Filtered: " << text << std::endl;
segment.chinese = text;
segment.was_filtered = true;
segment.filter_reason = "hallucination";
segment.end_time = std::chrono::system_clock::now();
if (session_logger_) session_logger_->logSegment(segment);
continue;
}
@ -275,16 +352,24 @@ void Pipeline::processingThread() {
ui_->addAudioCost(duration);
}
// Translate with Claude
// Translate with Claude (measure latency)
auto claude_start = std::chrono::steady_clock::now();
auto claude_result = claude_client_->translate(
text,
config.getClaudeConfig().system_prompt,
config.getClaudeConfig().max_tokens,
config.getClaudeConfig().temperature
);
auto claude_end = std::chrono::steady_clock::now();
segment.claude_latency_ms = std::chrono::duration<float, std::milli>(claude_end - claude_start).count();
if (!claude_result.has_value()) {
std::cerr << "Claude translation failed" << std::endl;
segment.chinese = text;
segment.was_filtered = true;
segment.filter_reason = "claude_api_error";
segment.end_time = std::chrono::system_clock::now();
if (session_logger_) session_logger_->logSegment(segment);
continue;
}
@ -293,6 +378,14 @@ void Pipeline::processingThread() {
ui_->addClaudeCost();
}
// Log successful segment
segment.chinese = text;
segment.french = claude_result->text;
segment.end_time = std::chrono::system_clock::now();
if (session_logger_) {
session_logger_->logSegment(segment);
}
// Simple accumulation
if (!accumulated_chinese_.empty()) {
accumulated_chinese_ += " ";
@ -317,12 +410,13 @@ void Pipeline::processingThread() {
void Pipeline::update() {
if (!ui_) return;
// Sync VAD thresholds from UI to AudioCapture
// Sync VAD thresholds and input gain from UI to AudioCapture
if (audio_capture_) {
audio_capture_->setVadThresholds(
ui_->getVadThreshold(),
ui_->getVadPeakThreshold()
);
audio_capture_->setInputGain(ui_->getInputGain());
// Update UI with audio levels
ui_->setCurrentRMS(audio_capture_->getCurrentRMS());

View File

@ -5,6 +5,7 @@
#include <atomic>
#include <string>
#include <vector>
#include <chrono>
#include "../utils/ThreadSafeQueue.h"
namespace secondvoice {
@ -14,11 +15,15 @@ class WhisperClient;
class ClaudeClient;
class TranslationUI;
class AudioBuffer;
class SessionLogger;
struct AudioChunk {
std::vector<float> data;
int sample_rate;
int channels;
float rms_level;
float peak_level;
std::chrono::system_clock::time_point timestamp;
};
class Pipeline {
@ -48,6 +53,7 @@ private:
std::unique_ptr<ClaudeClient> claude_client_;
std::unique_ptr<TranslationUI> ui_;
std::unique_ptr<AudioBuffer> full_recording_;
std::unique_ptr<SessionLogger> session_logger_;
ThreadSafeQueue<AudioChunk> audio_queue_;

View File

@ -400,6 +400,17 @@ void TranslationUI::renderAudioPanel() {
ImGui::Separator();
ImGui::Spacing();
// Input Gain (Amplifier)
ImGui::Text("Input Gain");
ImGui::SliderFloat("##input_gain", &input_gain_, 0.5f, 5.0f, "x%.1f");
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Amplify microphone input (1.0 = normal)");
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// VAD Threshold sliders
ImGui::Text("VAD Settings");
ImGui::Spacing();

View File

@ -44,6 +44,9 @@ public:
float getVadThreshold() const { return vad_threshold_; }
float getVadPeakThreshold() const { return vad_peak_threshold_; }
// Input gain (amplifier)
float getInputGain() const { return input_gain_; }
private:
void renderAccumulated();
void renderTranslations();
@ -71,6 +74,7 @@ private:
float current_peak_ = 0.0f;
float vad_threshold_ = 0.02f; // 2x higher to avoid false triggers
float vad_peak_threshold_ = 0.08f; // 2x higher
float input_gain_ = 1.0f; // Input amplifier (1.0 = no change)
// Cost tracking
float total_audio_seconds_ = 0.0f;

412
src/utils/SessionLogger.cpp Normal file
View File

@ -0,0 +1,412 @@
#include "SessionLogger.h"
#include <iostream>
#include <iomanip>
#include <sstream>
#include <filesystem>
#include <fstream>
#include <cstdlib>
#include <ctime>
#include <cstring>
// For Opus encoding (FetchContent paths)
#include <opus.h>
#include <ogg/ogg.h>
namespace {
// Simple FNV-1a hash for audio fingerprinting
uint64_t fnv1a_hash(const float* data, size_t count) {
const uint64_t FNV_PRIME = 0x100000001b3ULL;
const uint64_t FNV_OFFSET = 0xcbf29ce484222325ULL;
uint64_t hash = FNV_OFFSET;
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(data);
size_t byte_count = count * sizeof(float);
for (size_t i = 0; i < byte_count; ++i) {
hash ^= bytes[i];
hash *= FNV_PRIME;
}
return hash;
}
std::string hash_to_hex(uint64_t hash) {
std::stringstream ss;
ss << std::hex << std::setfill('0') << std::setw(16) << hash;
return ss.str();
}
}
namespace secondvoice {
SessionLogger::SessionLogger() {
metadata_ = {};
std::srand(static_cast<unsigned int>(std::time(nullptr))); // For OGG stream IDs
}
SessionLogger::~SessionLogger() {
if (session_active_) {
endSession();
}
}
bool SessionLogger::startSession() {
std::lock_guard<std::mutex> lock(mutex_);
// Generate session ID from timestamp
auto now = std::chrono::system_clock::now();
auto time_t = std::chrono::system_clock::to_time_t(now);
std::stringstream ss;
ss << std::put_time(std::localtime(&time_t), "%Y-%m-%d_%H%M%S");
metadata_.session_id = ss.str();
metadata_.start_time = now;
// Create directory structure: sessions/YYYY-MM-DD_HHMMSS/
session_path_ = "sessions/" + metadata_.session_id;
segments_path_ = session_path_ + "/segments";
try {
std::filesystem::create_directories(segments_path_);
std::filesystem::create_directories(session_path_ + "/audio");
} catch (const std::exception& e) {
std::cerr << "[SessionLogger] Failed to create directories: " << e.what() << std::endl;
return false;
}
// Initialize metadata
metadata_.total_segments = 0;
metadata_.filtered_segments = 0;
metadata_.total_audio_seconds = 0.0f;
metadata_.total_cost_estimate = 0.0f;
next_segment_id_ = 1;
segments_.clear();
session_active_ = true;
std::cout << "[SessionLogger] Started session: " << metadata_.session_id << std::endl;
return true;
}
void SessionLogger::endSession() {
std::lock_guard<std::mutex> lock(mutex_);
if (!session_active_) return;
metadata_.end_time = std::chrono::system_clock::now();
metadata_.total_segments = static_cast<int>(segments_.size());
writeSessionJson();
session_active_ = false;
std::cout << "[SessionLogger] Ended session: " << metadata_.session_id
<< " (" << metadata_.total_segments << " segments)" << std::endl;
}
void SessionLogger::logSegment(const SegmentData& segment) {
std::lock_guard<std::mutex> lock(mutex_);
if (!session_active_) return;
segments_.push_back(segment);
next_segment_id_++; // Increment for next segment
if (segment.was_filtered) {
metadata_.filtered_segments++;
}
metadata_.total_audio_seconds += segment.duration_seconds;
// Estimate cost: Whisper $0.006/min, Claude ~$0.001/call
float whisper_cost = (segment.duration_seconds / 60.0f) * 0.006f;
float claude_cost = segment.was_filtered ? 0.0f : 0.001f;
metadata_.total_cost_estimate += whisper_cost + claude_cost;
writeSegmentJson(segment);
}
std::string SessionLogger::saveSegmentAudio(int segment_id, const std::vector<float>& audio_data,
int sample_rate, int channels,
std::vector<std::string>& out_hashes) {
std::lock_guard<std::mutex> lock(mutex_);
out_hashes.clear();
if (!session_active_) return "";
// Calculate hash per second of audio
size_t samples_per_second = sample_rate * channels;
size_t num_seconds = (audio_data.size() + samples_per_second - 1) / samples_per_second;
for (size_t sec = 0; sec < num_seconds; ++sec) {
size_t start = sec * samples_per_second;
size_t end = std::min(start + samples_per_second, audio_data.size());
size_t count = end - start;
uint64_t hash = fnv1a_hash(audio_data.data() + start, count);
out_hashes.push_back(hash_to_hex(hash));
}
std::cout << "[SessionLogger] Audio hashes (" << num_seconds << "s): ";
for (const auto& h : out_hashes) {
std::cout << h.substr(0, 8) << " "; // Print first 8 chars for brevity
}
std::cout << std::endl;
// Format: audio/001.opus
std::stringstream filename_ss;
filename_ss << std::setfill('0') << std::setw(3) << segment_id << ".opus";
std::string filename = filename_ss.str();
std::string filepath = session_path_ + "/audio/" + filename;
// Encode to Opus/OGG
int error;
OpusEncoder* encoder = opus_encoder_create(sample_rate, channels, OPUS_APPLICATION_VOIP, &error);
if (error != OPUS_OK || !encoder) {
std::cerr << "[SessionLogger] Failed to create Opus encoder: " << opus_strerror(error) << std::endl;
return "";
}
// Set bitrate to 24kbps for speech
opus_encoder_ctl(encoder, OPUS_SET_BITRATE(24000));
// Open output file
std::ofstream outfile(filepath, std::ios::binary);
if (!outfile.is_open()) {
opus_encoder_destroy(encoder);
std::cerr << "[SessionLogger] Failed to open file: " << filepath << std::endl;
return "";
}
// OGG stream setup
ogg_stream_state os;
ogg_stream_init(&os, rand());
// Write Opus header
unsigned char header[19];
memcpy(header, "OpusHead", 8);
header[8] = 1; // version
header[9] = channels;
header[10] = 0; header[11] = 0; // pre-skip (little endian)
uint32_t rate = sample_rate;
memcpy(&header[12], &rate, 4); // sample rate (little endian)
header[16] = 0; header[17] = 0; // output gain
header[18] = 0; // channel mapping
ogg_packet op;
op.packet = header;
op.bytes = 19;
op.b_o_s = 1;
op.e_o_s = 0;
op.granulepos = 0;
op.packetno = 0;
ogg_stream_packetin(&os, &op);
ogg_page og;
while (ogg_stream_flush(&os, &og)) {
outfile.write(reinterpret_cast<char*>(og.header), og.header_len);
outfile.write(reinterpret_cast<char*>(og.body), og.body_len);
}
// Write Opus comment header
unsigned char comment[27];
memcpy(comment, "OpusTags", 8);
uint32_t vendor_len = 11;
memcpy(&comment[8], &vendor_len, 4);
memcpy(&comment[12], "SecondVoice", 11);
uint32_t num_comments = 0;
memcpy(&comment[23], &num_comments, 4);
op.packet = comment;
op.bytes = 27;
op.b_o_s = 0;
op.e_o_s = 0;
op.granulepos = 0;
op.packetno = 1;
ogg_stream_packetin(&os, &op);
while (ogg_stream_flush(&os, &og)) {
outfile.write(reinterpret_cast<char*>(og.header), og.header_len);
outfile.write(reinterpret_cast<char*>(og.body), og.body_len);
}
// Encode audio frames
const int frame_size = sample_rate / 50; // 20ms frames
std::vector<unsigned char> opus_buffer(4000);
int64_t granulepos = 0;
int packetno = 2;
for (size_t i = 0; i < audio_data.size(); i += frame_size * channels) {
size_t remaining = audio_data.size() - i;
size_t samples_to_encode = std::min(static_cast<size_t>(frame_size * channels), remaining);
// Pad with zeros if needed
std::vector<float> frame(frame_size * channels, 0.0f);
std::copy(audio_data.begin() + i, audio_data.begin() + i + samples_to_encode, frame.begin());
int encoded_bytes = opus_encode_float(encoder, frame.data(), frame_size,
opus_buffer.data(), opus_buffer.size());
if (encoded_bytes < 0) {
std::cerr << "[SessionLogger] Opus encode error: " << opus_strerror(encoded_bytes) << std::endl;
continue;
}
granulepos += frame_size;
bool is_last = (i + frame_size * channels >= audio_data.size());
op.packet = opus_buffer.data();
op.bytes = encoded_bytes;
op.b_o_s = 0;
op.e_o_s = is_last ? 1 : 0;
op.granulepos = granulepos;
op.packetno = packetno++;
ogg_stream_packetin(&os, &op);
while (is_last ? ogg_stream_flush(&os, &og) : ogg_stream_pageout(&os, &og)) {
outfile.write(reinterpret_cast<char*>(og.header), og.header_len);
outfile.write(reinterpret_cast<char*>(og.body), og.body_len);
}
}
ogg_stream_clear(&os);
opus_encoder_destroy(encoder);
outfile.close();
std::cout << "[SessionLogger] Saved audio: " << filepath << std::endl;
return filename;
}
void SessionLogger::setVadSettings(float rms_thresh, float peak_thresh) {
std::lock_guard<std::mutex> lock(mutex_);
metadata_.vad_rms_threshold = rms_thresh;
metadata_.vad_peak_threshold = peak_thresh;
}
void SessionLogger::setModels(const std::string& whisper_model, const std::string& claude_model) {
std::lock_guard<std::mutex> lock(mutex_);
metadata_.whisper_model = whisper_model;
metadata_.claude_model = claude_model;
}
std::vector<std::string> SessionLogger::getRecentTranscriptions(int count) const {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<std::string> recent;
int start = std::max(0, static_cast<int>(segments_.size()) - count);
for (int i = start; i < static_cast<int>(segments_.size()); ++i) {
if (!segments_[i].was_filtered && !segments_[i].chinese.empty()) {
recent.push_back(segments_[i].chinese);
}
}
return recent;
}
void SessionLogger::writeSessionJson() {
std::string filepath = session_path_ + "/session.json";
std::ofstream file(filepath, std::ios::out | std::ios::binary);
if (!file.is_open()) {
std::cerr << "[SessionLogger] Failed to write session.json" << std::endl;
return;
}
// Write UTF-8 BOM
file << "\xEF\xBB\xBF";
// Manual JSON construction (to avoid extra dependencies)
file << "{\n";
file << " \"session_id\": \"" << metadata_.session_id << "\",\n";
file << " \"start_time\": \"" << formatTimestamp(metadata_.start_time) << "\",\n";
file << " \"end_time\": \"" << formatTimestamp(metadata_.end_time) << "\",\n";
file << " \"total_segments\": " << metadata_.total_segments << ",\n";
file << " \"filtered_segments\": " << metadata_.filtered_segments << ",\n";
file << " \"total_audio_seconds\": " << std::fixed << std::setprecision(2) << metadata_.total_audio_seconds << ",\n";
file << " \"total_cost_estimate\": " << std::fixed << std::setprecision(4) << metadata_.total_cost_estimate << ",\n";
file << " \"vad_settings\": {\n";
file << " \"rms_threshold\": " << std::fixed << std::setprecision(4) << metadata_.vad_rms_threshold << ",\n";
file << " \"peak_threshold\": " << std::fixed << std::setprecision(4) << metadata_.vad_peak_threshold << "\n";
file << " },\n";
file << " \"models\": {\n";
file << " \"whisper\": \"" << metadata_.whisper_model << "\",\n";
file << " \"claude\": \"" << metadata_.claude_model << "\"\n";
file << " }\n";
file << "}\n";
file.close();
std::cout << "[SessionLogger] Wrote " << filepath << std::endl;
}
void SessionLogger::writeSegmentJson(const SegmentData& segment) {
std::stringstream filename_ss;
filename_ss << segments_path_ << "/" << std::setfill('0') << std::setw(3) << segment.id << ".json";
std::string filepath = filename_ss.str();
std::ofstream file(filepath, std::ios::out | std::ios::binary);
if (!file.is_open()) {
std::cerr << "[SessionLogger] Failed to write segment JSON: " << filepath << std::endl;
return;
}
// Write UTF-8 BOM
file << "\xEF\xBB\xBF";
// Escape JSON strings
auto escapeJson = [](const std::string& s) -> std::string {
std::string result;
for (char c : s) {
switch (c) {
case '"': result += "\\\""; break;
case '\\': result += "\\\\"; break;
case '\n': result += "\\n"; break;
case '\r': result += "\\r"; break;
case '\t': result += "\\t"; break;
default: result += c;
}
}
return result;
};
file << "{\n";
file << " \"id\": " << segment.id << ",\n";
file << " \"chinese\": \"" << escapeJson(segment.chinese) << "\",\n";
file << " \"french\": \"" << escapeJson(segment.french) << "\",\n";
file << " \"audio\": {\n";
file << " \"duration_seconds\": " << std::fixed << std::setprecision(3) << segment.duration_seconds << ",\n";
file << " \"rms_level\": " << std::fixed << std::setprecision(4) << segment.rms_level << ",\n";
file << " \"peak_level\": " << std::fixed << std::setprecision(4) << segment.peak_level << ",\n";
file << " \"filename\": \"" << escapeJson(segment.audio_filename) << "\",\n";
file << " \"hashes_per_second\": [";
for (size_t i = 0; i < segment.audio_hashes.size(); ++i) {
if (i > 0) file << ", ";
file << "\"" << segment.audio_hashes[i] << "\"";
}
file << "]\n";
file << " },\n";
file << " \"timestamps\": {\n";
file << " \"start\": \"" << formatTimestamp(segment.start_time) << "\",\n";
file << " \"end\": \"" << formatTimestamp(segment.end_time) << "\"\n";
file << " },\n";
file << " \"processing\": {\n";
file << " \"whisper_latency_ms\": " << std::fixed << std::setprecision(1) << segment.whisper_latency_ms << ",\n";
file << " \"claude_latency_ms\": " << std::fixed << std::setprecision(1) << segment.claude_latency_ms << ",\n";
file << " \"was_filtered\": " << (segment.was_filtered ? "true" : "false");
if (segment.was_filtered) {
file << ",\n \"filter_reason\": \"" << escapeJson(segment.filter_reason) << "\"";
}
file << "\n }\n";
file << "}\n";
file.close();
}
std::string SessionLogger::formatTimestamp(const std::chrono::system_clock::time_point& tp) const {
auto time_t = std::chrono::system_clock::to_time_t(tp);
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(tp.time_since_epoch()) % 1000;
std::stringstream ss;
ss << std::put_time(std::localtime(&time_t), "%Y-%m-%dT%H:%M:%S");
ss << "." << std::setfill('0') << std::setw(3) << ms.count();
return ss.str();
}
} // namespace secondvoice

104
src/utils/SessionLogger.h Normal file
View File

@ -0,0 +1,104 @@
#pragma once
#include <string>
#include <vector>
#include <chrono>
#include <fstream>
#include <mutex>
namespace secondvoice {
struct SegmentData {
int id;
std::string chinese;
std::string french;
// Audio metadata
float duration_seconds;
float rms_level;
float peak_level;
// Timestamps
std::chrono::system_clock::time_point start_time;
std::chrono::system_clock::time_point end_time;
// Processing info
float whisper_latency_ms;
float claude_latency_ms;
bool was_filtered;
std::string filter_reason;
// Audio file (optional)
std::string audio_filename;
// Audio fingerprint - hash per second for duplicate detection
std::vector<std::string> audio_hashes;
};
struct SessionMetadata {
std::string session_id;
std::chrono::system_clock::time_point start_time;
std::chrono::system_clock::time_point end_time;
int total_segments;
int filtered_segments;
float total_audio_seconds;
float total_cost_estimate;
// VAD settings used
float vad_rms_threshold;
float vad_peak_threshold;
// Models used
std::string whisper_model;
std::string claude_model;
};
class SessionLogger {
public:
SessionLogger();
~SessionLogger();
// Start a new session - creates directory structure
bool startSession();
// End session - writes session.json
void endSession();
// Log a segment (called after transcription+translation)
void logSegment(const SegmentData& segment);
// Save audio data for a segment (returns filename)
// Also calculates audio_hashes (one hash per second)
std::string saveSegmentAudio(int segment_id, const std::vector<float>& audio_data,
int sample_rate, int channels,
std::vector<std::string>& out_hashes);
// Update session metadata
void setVadSettings(float rms_thresh, float peak_thresh);
void setModels(const std::string& whisper_model, const std::string& claude_model);
// Getters
const std::string& getSessionPath() const { return session_path_; }
int getNextSegmentId() const { return next_segment_id_; }
// Get last N transcriptions for context (for Whisper prompt)
std::vector<std::string> getRecentTranscriptions(int count = 3) const;
private:
void writeSessionJson();
void writeSegmentJson(const SegmentData& segment);
std::string formatTimestamp(const std::chrono::system_clock::time_point& tp) const;
std::string session_path_;
std::string segments_path_;
SessionMetadata metadata_;
std::vector<SegmentData> segments_;
int next_segment_id_ = 1;
bool session_active_ = false;
mutable std::mutex mutex_;
};
} // namespace secondvoice

View File

@ -0,0 +1,329 @@
═══════════════════════════════════════════════════════════════
SecondVoice - Transcript Export
Date: 2025-11-23 19:36:08
Duration: 5:31
Segments: 75
═══════════════════════════════════════════════════════════════
───────────────────────────────────────────────────────────────
TEXTE COMPLET / FULL TEXT
───────────────────────────────────────────────────────────────
[中文 / Chinese]
對,兩個都是。 两个老鼠求我 好的 去年都没有过呀。 还和我公司。 六啊七 你叫她。 面试就是最专业的。 2025年12月7号上海市公考。 狗年就是七号。 他不是经理转业。 我叫他去口。 我查到了。 他是比你。 我们学校有考点。 忘掉了。 是不是翻译不过来? 有人骂人了。 不過來。 还有因素呢。 昨天就是行了。 太多声音了。 有人骂人了。 他多大? Si je parle en français, ça va aussi mettre du français? 你是没有中文呀。 两个老鼠求我,好的,今年都没有怪。 我们是超频安的。 我只去那儿了。 打电话。 不懂。 我去那边的路啊。 你到时候考试是 要不要过去等你。 我上次就觉得。 你好。 not working 他这样反应不太好。 一個房。 你上次是什么时候? 什么时候考的? 你好。 汪汪汪汪。 Je suis la meilleure. 那你還看不到。 起来 你不是直接把她清理的吗? 好些。 你好。 你好。 妈说这是饭局吗? 你是做什么的? 去一个什么学校? 非常感谢你。 那说这是饭局吗? 很欣喜。 有没有个。 对啊。 网路。 我還沒。 你好吗? 我没。 有什么音乐。 医院。 你也明白。 我没付钱。 来。 我在用 online API。 你要不要喝? 去里面找找呀。 你是我。 要走嗎? 我很。 的。 我打算应该下个月。
[Français / French]
Oui, les deux le sont. Deux souris me supplient D'accord L'année dernière, il n'y en avait pas du tout. Il travaille encore dans ma société. Six et sept Appelle-la. L'entretien est le plus professionnel. Le 7 décembre 2025, examen de la fonction publique à Shanghai. L'année du chien est le sept. Il n'est pas un ancien militaire devenu gestionnaire. Je lui ai demandé d'aller [contenu offensant]. J'ai trouvé. Il est mieux que toi. Notre école est un centre d'examen. J'ai oublié. Est-ce que c'est intraduisible ? Quelqu'un a insulté quelqu'un. Ne viens pas. Il y a d'autres facteurs. Hier, c'était bon. Il y a trop de bruit. Quelqu'un a insulté quelqu'un. Quel âge a-t-il ? Si je parle en français, ça va aussi mettre du français ? Tu ne sais pas parler chinois. Deux souris m'ont supplié, d'accord, cette année il n'y aura pas de problèmes. Nous sommes Superpower. Je n'y suis allé qu'une seule fois. Passer un coup de téléphone. Je ne comprends pas. Je vais par là-bas. Tu passeras l'examen à ce moment-là Je vais t'attendre là-bas. Je pensais déjà la dernière fois. Bonjour. Ne fonctionne pas Il réagit de manière pas très appropriée. Une chambre. Quand étais-tu la dernière fois ? Quand est-ce que tu as passé l'examen ? Bonjour. Ouaf ouaf ouaf ouaf. Je suis la meilleure. Tu ne peux toujours pas le voir. Debout Tu ne l'as pas directement nettoyée ? Ça va mieux. Bonjour. Bonjour. Maman, est-ce que c'est un repas d'affaires ? Que fais-tu dans la vie ? À quelle école vas-tu ? Merci beaucoup. Est-ce qu'on peut appeler ça un repas d'affaires ? Je suis très heureux. Il y a-t-il un ? Oui, c'est ça. Réseau. Je n'ai pas encore. Comment vas-tu ? Je n'ai pas. Quel type de musique y a-t-il ? Hôpital. Tu comprends aussi. Je n'ai pas payé. Viens. Je suis en train d'utiliser une API en ligne. Veux-tu boire ? Vas voir à l'intérieur. Tu es moi. Veux-tu partir ? Je suis. De. Je prévois de le faire le mois prochain.
───────────────────────────────────────────────────────────────
SEGMENTS DÉTAILLÉS / DETAILED SEGMENTS
───────────────────────────────────────────────────────────────
[Segment 1]
中文: 對,兩個都是。
FR: Oui, les deux le sont.
[Segment 2]
中文: 两个老鼠求我
FR: Deux souris me supplient
[Segment 3]
中文: 好的
FR: D'accord
[Segment 4]
中文: 去年都没有过呀。
FR: L'année dernière, il n'y en avait pas du tout.
[Segment 5]
中文: 还和我公司。
FR: Il travaille encore dans ma société.
[Segment 6]
中文: 六啊七
FR: Six et sept
[Segment 7]
中文: 你叫她。
FR: Appelle-la.
[Segment 8]
中文: 面试就是最专业的。
FR: L'entretien est le plus professionnel.
[Segment 9]
中文: 2025年12月7号上海市公考。
FR: Le 7 décembre 2025, examen de la fonction publique à Shanghai.
[Segment 10]
中文: 狗年就是七号。
FR: L'année du chien est le sept.
[Segment 11]
中文: 他不是经理转业。
FR: Il n'est pas un ancien militaire devenu gestionnaire.
[Segment 12]
中文: 我叫他去口。
FR: Je lui ai demandé d'aller [contenu offensant].
[Segment 13]
中文: 我查到了。
FR: J'ai trouvé.
[Segment 14]
中文: 他是比你。
FR: Il est mieux que toi.
[Segment 15]
中文: 我们学校有考点。
FR: Notre école est un centre d'examen.
[Segment 16]
中文: 忘掉了。
FR: J'ai oublié.
[Segment 17]
中文: 是不是翻译不过来?
FR: Est-ce que c'est intraduisible ?
[Segment 18]
中文: 有人骂人了。
FR: Quelqu'un a insulté quelqu'un.
[Segment 19]
中文: 不過來。
FR: Ne viens pas.
[Segment 20]
中文: 还有因素呢。
FR: Il y a d'autres facteurs.
[Segment 21]
中文: 昨天就是行了。
FR: Hier, c'était bon.
[Segment 22]
中文: 太多声音了。
FR: Il y a trop de bruit.
[Segment 23]
中文: 有人骂人了。
FR: Quelqu'un a insulté quelqu'un.
[Segment 24]
中文: 他多大?
FR: Quel âge a-t-il ?
[Segment 25]
中文: Si je parle en français, ça va aussi mettre du français?
FR: Si je parle en français, ça va aussi mettre du français ?
[Segment 26]
中文: 你是没有中文呀。
FR: Tu ne sais pas parler chinois.
[Segment 27]
中文: 两个老鼠求我,好的,今年都没有怪。
FR: Deux souris m'ont supplié, d'accord, cette année il n'y aura pas de problèmes.
[Segment 28]
中文: 我们是超频安的。
FR: Nous sommes Superpower.
[Segment 29]
中文: 我只去那儿了。
FR: Je n'y suis allé qu'une seule fois.
[Segment 30]
中文: 打电话。
FR: Passer un coup de téléphone.
[Segment 31]
中文: 不懂。
FR: Je ne comprends pas.
[Segment 32]
中文: 我去那边的路啊。
FR: Je vais par là-bas.
[Segment 33]
中文: 你到时候考试是
FR: Tu passeras l'examen à ce moment-là
[Segment 34]
中文: 要不要过去等你。
FR: Je vais t'attendre là-bas.
[Segment 35]
中文: 我上次就觉得。
FR: Je pensais déjà la dernière fois.
[Segment 36]
中文: 你好。
FR: Bonjour.
[Segment 37]
中文: not working
FR: Ne fonctionne pas
[Segment 38]
中文: 他这样反应不太好。
FR: Il réagit de manière pas très appropriée.
[Segment 39]
中文: 一個房。
FR: Une chambre.
[Segment 40]
中文: 你上次是什么时候?
FR: Quand étais-tu la dernière fois ?
[Segment 41]
中文: 什么时候考的?
FR: Quand est-ce que tu as passé l'examen ?
[Segment 42]
中文: 你好。
FR: Bonjour.
[Segment 43]
中文: 汪汪汪汪。
FR: Ouaf ouaf ouaf ouaf.
[Segment 44]
中文: Je suis la meilleure.
FR: Je suis la meilleure.
[Segment 45]
中文: 那你還看不到。
FR: Tu ne peux toujours pas le voir.
[Segment 46]
中文: 起来
FR: Debout
[Segment 47]
中文: 你不是直接把她清理的吗?
FR: Tu ne l'as pas directement nettoyée ?
[Segment 48]
中文: 好些。
FR: Ça va mieux.
[Segment 49]
中文: 你好。
FR: Bonjour.
[Segment 50]
中文: 你好。
FR: Bonjour.
[Segment 51]
中文: 妈说这是饭局吗?
FR: Maman, est-ce que c'est un repas d'affaires ?
[Segment 52]
中文: 你是做什么的?
FR: Que fais-tu dans la vie ?
[Segment 53]
中文: 去一个什么学校?
FR: À quelle école vas-tu ?
[Segment 54]
中文: 非常感谢你。
FR: Merci beaucoup.
[Segment 55]
中文: 那说这是饭局吗?
FR: Est-ce qu'on peut appeler ça un repas d'affaires ?
[Segment 56]
中文: 很欣喜。
FR: Je suis très heureux.
[Segment 57]
中文: 有没有个。
FR: Il y a-t-il un ?
[Segment 58]
中文: 对啊。
FR: Oui, c'est ça.
[Segment 59]
中文: 网路。
FR: Réseau.
[Segment 60]
中文: 我還沒。
FR: Je n'ai pas encore.
[Segment 61]
中文: 你好吗?
FR: Comment vas-tu ?
[Segment 62]
中文: 我没。
FR: Je n'ai pas.
[Segment 63]
中文: 有什么音乐。
FR: Quel type de musique y a-t-il ?
[Segment 64]
中文: 医院。
FR: Hôpital.
[Segment 65]
中文: 你也明白。
FR: Tu comprends aussi.
[Segment 66]
中文: 我没付钱。
FR: Je n'ai pas payé.
[Segment 67]
中文: 来。
FR: Viens.
[Segment 68]
中文: 我在用 online API。
FR: Je suis en train d'utiliser une API en ligne.
[Segment 69]
中文: 你要不要喝?
FR: Veux-tu boire ?
[Segment 70]
中文: 去里面找找呀。
FR: Vas voir à l'intérieur.
[Segment 71]
中文: 你是我。
FR: Tu es moi.
[Segment 72]
中文: 要走嗎?
FR: Veux-tu partir ?
[Segment 73]
中文: 我很。
FR: Je suis.
[Segment 74]
中文: 的。
FR: De.
[Segment 75]
中文: 我打算应该下个月。
FR: Je prévois de le faire le mois prochain.
───────────────────────────────────────────────────────────────
STATISTIQUES / STATISTICS
───────────────────────────────────────────────────────────────
Audio processed: 212 seconds
Whisper API calls: 75
Claude API calls: 75
Estimated cost: $0.0963
═══════════════════════════════════════════════════════════════

View File

@ -0,0 +1,329 @@
═══════════════════════════════════════════════════════════════
SecondVoice - Transcript Export
Date: 2025-11-23 19:36:12
Duration: 5:34
Segments: 75
═══════════════════════════════════════════════════════════════
───────────────────────────────────────────────────────────────
TEXTE COMPLET / FULL TEXT
───────────────────────────────────────────────────────────────
[中文 / Chinese]
對,兩個都是。 两个老鼠求我 好的 去年都没有过呀。 还和我公司。 六啊七 你叫她。 面试就是最专业的。 2025年12月7号上海市公考。 狗年就是七号。 他不是经理转业。 我叫他去口。 我查到了。 他是比你。 我们学校有考点。 忘掉了。 是不是翻译不过来? 有人骂人了。 不過來。 还有因素呢。 昨天就是行了。 太多声音了。 有人骂人了。 他多大? Si je parle en français, ça va aussi mettre du français? 你是没有中文呀。 两个老鼠求我,好的,今年都没有怪。 我们是超频安的。 我只去那儿了。 打电话。 不懂。 我去那边的路啊。 你到时候考试是 要不要过去等你。 我上次就觉得。 你好。 not working 他这样反应不太好。 一個房。 你上次是什么时候? 什么时候考的? 你好。 汪汪汪汪。 Je suis la meilleure. 那你還看不到。 起来 你不是直接把她清理的吗? 好些。 你好。 你好。 妈说这是饭局吗? 你是做什么的? 去一个什么学校? 非常感谢你。 那说这是饭局吗? 很欣喜。 有没有个。 对啊。 网路。 我還沒。 你好吗? 我没。 有什么音乐。 医院。 你也明白。 我没付钱。 来。 我在用 online API。 你要不要喝? 去里面找找呀。 你是我。 要走嗎? 我很。 的。 我打算应该下个月。
[Français / French]
Oui, les deux le sont. Deux souris me supplient D'accord L'année dernière, il n'y en avait pas du tout. Il travaille encore dans ma société. Six et sept Appelle-la. L'entretien est le plus professionnel. Le 7 décembre 2025, examen de la fonction publique à Shanghai. L'année du chien est le sept. Il n'est pas un ancien militaire devenu gestionnaire. Je lui ai demandé d'aller [contenu offensant]. J'ai trouvé. Il est mieux que toi. Notre école est un centre d'examen. J'ai oublié. Est-ce que c'est intraduisible ? Quelqu'un a insulté quelqu'un. Ne viens pas. Il y a d'autres facteurs. Hier, c'était bon. Il y a trop de bruit. Quelqu'un a insulté quelqu'un. Quel âge a-t-il ? Si je parle en français, ça va aussi mettre du français ? Tu ne sais pas parler chinois. Deux souris m'ont supplié, d'accord, cette année il n'y aura pas de problèmes. Nous sommes Superpower. Je n'y suis allé qu'une seule fois. Passer un coup de téléphone. Je ne comprends pas. Je vais par là-bas. Tu passeras l'examen à ce moment-là Je vais t'attendre là-bas. Je pensais déjà la dernière fois. Bonjour. Ne fonctionne pas Il réagit de manière pas très appropriée. Une chambre. Quand étais-tu la dernière fois ? Quand est-ce que tu as passé l'examen ? Bonjour. Ouaf ouaf ouaf ouaf. Je suis la meilleure. Tu ne peux toujours pas le voir. Debout Tu ne l'as pas directement nettoyée ? Ça va mieux. Bonjour. Bonjour. Maman, est-ce que c'est un repas d'affaires ? Que fais-tu dans la vie ? À quelle école vas-tu ? Merci beaucoup. Est-ce qu'on peut appeler ça un repas d'affaires ? Je suis très heureux. Il y a-t-il un ? Oui, c'est ça. Réseau. Je n'ai pas encore. Comment vas-tu ? Je n'ai pas. Quel type de musique y a-t-il ? Hôpital. Tu comprends aussi. Je n'ai pas payé. Viens. Je suis en train d'utiliser une API en ligne. Veux-tu boire ? Vas voir à l'intérieur. Tu es moi. Veux-tu partir ? Je suis. De. Je prévois de le faire le mois prochain.
───────────────────────────────────────────────────────────────
SEGMENTS DÉTAILLÉS / DETAILED SEGMENTS
───────────────────────────────────────────────────────────────
[Segment 1]
中文: 對,兩個都是。
FR: Oui, les deux le sont.
[Segment 2]
中文: 两个老鼠求我
FR: Deux souris me supplient
[Segment 3]
中文: 好的
FR: D'accord
[Segment 4]
中文: 去年都没有过呀。
FR: L'année dernière, il n'y en avait pas du tout.
[Segment 5]
中文: 还和我公司。
FR: Il travaille encore dans ma société.
[Segment 6]
中文: 六啊七
FR: Six et sept
[Segment 7]
中文: 你叫她。
FR: Appelle-la.
[Segment 8]
中文: 面试就是最专业的。
FR: L'entretien est le plus professionnel.
[Segment 9]
中文: 2025年12月7号上海市公考。
FR: Le 7 décembre 2025, examen de la fonction publique à Shanghai.
[Segment 10]
中文: 狗年就是七号。
FR: L'année du chien est le sept.
[Segment 11]
中文: 他不是经理转业。
FR: Il n'est pas un ancien militaire devenu gestionnaire.
[Segment 12]
中文: 我叫他去口。
FR: Je lui ai demandé d'aller [contenu offensant].
[Segment 13]
中文: 我查到了。
FR: J'ai trouvé.
[Segment 14]
中文: 他是比你。
FR: Il est mieux que toi.
[Segment 15]
中文: 我们学校有考点。
FR: Notre école est un centre d'examen.
[Segment 16]
中文: 忘掉了。
FR: J'ai oublié.
[Segment 17]
中文: 是不是翻译不过来?
FR: Est-ce que c'est intraduisible ?
[Segment 18]
中文: 有人骂人了。
FR: Quelqu'un a insulté quelqu'un.
[Segment 19]
中文: 不過來。
FR: Ne viens pas.
[Segment 20]
中文: 还有因素呢。
FR: Il y a d'autres facteurs.
[Segment 21]
中文: 昨天就是行了。
FR: Hier, c'était bon.
[Segment 22]
中文: 太多声音了。
FR: Il y a trop de bruit.
[Segment 23]
中文: 有人骂人了。
FR: Quelqu'un a insulté quelqu'un.
[Segment 24]
中文: 他多大?
FR: Quel âge a-t-il ?
[Segment 25]
中文: Si je parle en français, ça va aussi mettre du français?
FR: Si je parle en français, ça va aussi mettre du français ?
[Segment 26]
中文: 你是没有中文呀。
FR: Tu ne sais pas parler chinois.
[Segment 27]
中文: 两个老鼠求我,好的,今年都没有怪。
FR: Deux souris m'ont supplié, d'accord, cette année il n'y aura pas de problèmes.
[Segment 28]
中文: 我们是超频安的。
FR: Nous sommes Superpower.
[Segment 29]
中文: 我只去那儿了。
FR: Je n'y suis allé qu'une seule fois.
[Segment 30]
中文: 打电话。
FR: Passer un coup de téléphone.
[Segment 31]
中文: 不懂。
FR: Je ne comprends pas.
[Segment 32]
中文: 我去那边的路啊。
FR: Je vais par là-bas.
[Segment 33]
中文: 你到时候考试是
FR: Tu passeras l'examen à ce moment-là
[Segment 34]
中文: 要不要过去等你。
FR: Je vais t'attendre là-bas.
[Segment 35]
中文: 我上次就觉得。
FR: Je pensais déjà la dernière fois.
[Segment 36]
中文: 你好。
FR: Bonjour.
[Segment 37]
中文: not working
FR: Ne fonctionne pas
[Segment 38]
中文: 他这样反应不太好。
FR: Il réagit de manière pas très appropriée.
[Segment 39]
中文: 一個房。
FR: Une chambre.
[Segment 40]
中文: 你上次是什么时候?
FR: Quand étais-tu la dernière fois ?
[Segment 41]
中文: 什么时候考的?
FR: Quand est-ce que tu as passé l'examen ?
[Segment 42]
中文: 你好。
FR: Bonjour.
[Segment 43]
中文: 汪汪汪汪。
FR: Ouaf ouaf ouaf ouaf.
[Segment 44]
中文: Je suis la meilleure.
FR: Je suis la meilleure.
[Segment 45]
中文: 那你還看不到。
FR: Tu ne peux toujours pas le voir.
[Segment 46]
中文: 起来
FR: Debout
[Segment 47]
中文: 你不是直接把她清理的吗?
FR: Tu ne l'as pas directement nettoyée ?
[Segment 48]
中文: 好些。
FR: Ça va mieux.
[Segment 49]
中文: 你好。
FR: Bonjour.
[Segment 50]
中文: 你好。
FR: Bonjour.
[Segment 51]
中文: 妈说这是饭局吗?
FR: Maman, est-ce que c'est un repas d'affaires ?
[Segment 52]
中文: 你是做什么的?
FR: Que fais-tu dans la vie ?
[Segment 53]
中文: 去一个什么学校?
FR: À quelle école vas-tu ?
[Segment 54]
中文: 非常感谢你。
FR: Merci beaucoup.
[Segment 55]
中文: 那说这是饭局吗?
FR: Est-ce qu'on peut appeler ça un repas d'affaires ?
[Segment 56]
中文: 很欣喜。
FR: Je suis très heureux.
[Segment 57]
中文: 有没有个。
FR: Il y a-t-il un ?
[Segment 58]
中文: 对啊。
FR: Oui, c'est ça.
[Segment 59]
中文: 网路。
FR: Réseau.
[Segment 60]
中文: 我還沒。
FR: Je n'ai pas encore.
[Segment 61]
中文: 你好吗?
FR: Comment vas-tu ?
[Segment 62]
中文: 我没。
FR: Je n'ai pas.
[Segment 63]
中文: 有什么音乐。
FR: Quel type de musique y a-t-il ?
[Segment 64]
中文: 医院。
FR: Hôpital.
[Segment 65]
中文: 你也明白。
FR: Tu comprends aussi.
[Segment 66]
中文: 我没付钱。
FR: Je n'ai pas payé.
[Segment 67]
中文: 来。
FR: Viens.
[Segment 68]
中文: 我在用 online API。
FR: Je suis en train d'utiliser une API en ligne.
[Segment 69]
中文: 你要不要喝?
FR: Veux-tu boire ?
[Segment 70]
中文: 去里面找找呀。
FR: Vas voir à l'intérieur.
[Segment 71]
中文: 你是我。
FR: Tu es moi.
[Segment 72]
中文: 要走嗎?
FR: Veux-tu partir ?
[Segment 73]
中文: 我很。
FR: Je suis.
[Segment 74]
中文: 的。
FR: De.
[Segment 75]
中文: 我打算应该下个月。
FR: Je prévois de le faire le mois prochain.
───────────────────────────────────────────────────────────────
STATISTIQUES / STATISTICS
───────────────────────────────────────────────────────────────
Audio processed: 212 seconds
Whisper API calls: 75
Claude API calls: 75
Estimated cost: $0.0963
═══════════════════════════════════════════════════════════════

View File

@ -0,0 +1,41 @@
═══════════════════════════════════════════════════════════════
SecondVoice - Transcript Export
Date: 2025-11-24 08:30:29
Duration: 0:46
Segments: 3
═══════════════════════════════════════════════════════════════
───────────────────────────────────────────────────────────────
TEXTE COMPLET / FULL TEXT
───────────────────────────────────────────────────────────────
[中文 / Chinese]
我很忙。 你会说英文吗? 我也不知道。
[Français / French]
Je suis très occupé. Parles-tu anglais ? Je ne sais pas non plus.
───────────────────────────────────────────────────────────────
SEGMENTS DÉTAILLÉS / DETAILED SEGMENTS
───────────────────────────────────────────────────────────────
[Segment 1]
中文: 我很忙。
FR: Je suis très occupé.
[Segment 2]
中文: 你会说英文吗?
FR: Parles-tu anglais ?
[Segment 3]
中文: 我也不知道。
FR: Je ne sais pas non plus.
───────────────────────────────────────────────────────────────
STATISTIQUES / STATISTICS
───────────────────────────────────────────────────────────────
Audio processed: 3 seconds
Whisper API calls: 3
Claude API calls: 3
Estimated cost: $0.0033
═══════════════════════════════════════════════════════════════