Compare commits

..

2 Commits

Author SHA1 Message Date
1db83b7bce chore: Add sessions/ and .claudiomiro/ to gitignore
Exclude runtime-generated session logs and local config from version control.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:40:58 +08:00
3ec2a8beca 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>
2025-11-28 12:17:21 +08:00
60 changed files with 1779 additions and 703 deletions

334
README.md
View File

@ -4,50 +4,16 @@ Real-time Chinese to French translation system for live meetings.
## Overview ## Overview
SecondVoice captures audio, transcribes Chinese speech using OpenAI's Whisper API (gpt-4o-mini-transcribe), and translates it to French using Claude AI in real-time. Designed for understanding Chinese meetings, calls, and conversations on the fly. SecondVoice captures audio, transcribes Chinese speech using OpenAI's Whisper API, and translates it to French using Claude AI in real-time. Perfect for understanding Chinese meetings on the fly.
### Why This Project?
Built to solve a real need: understanding Chinese meetings in real-time without constant reliance on bilingual support. Perfect for:
- Business meetings with Chinese speakers
- Family/administrative calls
- Professional conferences
- Any live Chinese conversation where real-time comprehension is needed
**Status**: MVP complete, actively being debugged and improved based on real-world usage.
## Quick Start
### Windows (MinGW) - Recommended
```batch
# First-time setup
.\setup_mingw.bat
# Build
.\build_mingw.bat
# Run
cd build\mingw-Release
SecondVoice.exe
```
**Requirements**: `.env` file with `OPENAI_API_KEY` and `ANTHROPIC_API_KEY`, plus a working microphone.
See full setup instructions below for other platforms.
## Features ## Features
- 🎤 **Real-time audio capture** with Voice Activity Detection (VAD) - 🎤 Real-time audio capture
- 🔇 **Noise reduction** using RNNoise neural network - 🗣️ Chinese speech-to-text (Whisper API)
- 🗣️ **Chinese speech-to-text** via Whisper API (gpt-4o-mini-transcribe) - 🌐 Chinese to French translation (Claude API)
- 🧠 **Hallucination filtering** - removes known Whisper artifacts - 🖥️ Clean ImGui interface
- 🌐 **Chinese to French translation** via Claude AI (claude-haiku-4-20250514) - 💾 Full recording saved to disk
- 🖥️ **Clean ImGui interface** with adjustable VAD thresholds - ⚙️ Configurable chunk sizes and settings
- 💾 **Full session recording** with structured logging
- 📊 **Session archival** - audio, transcripts, translations, and metadata
- ⚡ **Opus compression** - 46x bandwidth reduction (16kHz PCM → 24kbps Opus)
- ⚙️ **Configurable settings** via config.json
## Requirements ## Requirements
@ -150,138 +116,20 @@ The application will:
## Architecture ## Architecture
``` ```
Audio Input (16kHz mono) Audio Capture (PortAudio)
Voice Activity Detection (VAD) - RMS + Peak thresholds Whisper API (Speech-to-Text)
Noise Reduction (RNNoise) - 16→48→16 kHz resampling Claude API (Translation)
Opus Encoding (24kbps OGG) - 46x compression ImGui UI (Display)
Whisper API (gpt-4o-mini-transcribe) - Chinese STT
Hallucination Filter - Remove known artifacts
Claude API (claude-haiku-4) - Chinese → French translation
ImGui UI Display + Session Logging
``` ```
### Threading Model (3 threads) ### Threading Model
1. **Audio Thread** (`Pipeline::audioThread`) - **Thread 1**: Audio capture (PortAudio callback)
- PortAudio callback captures 16kHz mono audio - **Thread 2**: AI processing (Whisper + Claude API calls)
- Applies VAD (Voice Activity Detection) using RMS + Peak thresholds - **Thread 3**: UI rendering (ImGui + OpenGL)
- Pushes speech chunks to processing queue
2. **Processing Thread** (`Pipeline::processingThread`)
- Consumes audio chunks from queue
- Applies RNNoise denoising (upsampled to 48kHz → denoised → downsampled to 16kHz)
- Encodes to Opus/OGG for bandwidth efficiency
- Calls Whisper API for Chinese transcription
- Filters known hallucinations (YouTube phrases, music markers, etc.)
- Calls Claude API for French translation
- Logs to session files
3. **UI Thread** (main)
- GLFW/ImGui rendering loop (must run on main thread)
- Displays real-time transcription and translation
- Allows runtime VAD threshold adjustment
- Handles user controls (stop recording, etc.)
### Core Components
**Audio Processing**:
- `AudioCapture.cpp` - PortAudio wrapper with VAD-based segmentation
- `AudioBuffer.cpp` - Accumulates samples, exports WAV/Opus
- `NoiseReducer.cpp` - RNNoise denoising with resampling
**API Clients**:
- `WhisperClient.cpp` - OpenAI Whisper API (multipart/form-data)
- `ClaudeClient.cpp` - Anthropic Claude API (JSON)
- `WinHttpClient.cpp` - Native Windows HTTP client (replaced libcurl)
**Core Logic**:
- `Pipeline.cpp` - Orchestrates audio → transcription → translation flow
- `TranslationUI.cpp` - ImGui interface with VAD controls
**Utilities**:
- `Config.cpp` - Loads config.json + .env
- `ThreadSafeQueue.h` - Lock-free queue for audio chunks
## Known Issues & Active Debugging
**Status**: Real-world testing has identified issues with degraded audio conditions (see `PLAN_DEBUG.md` for details).
### Current Problems
Based on transcript analysis from actual meetings (November 2025):
1. **VAD cutting speech too early**
- Voice Activity Detection triggers end-of-segment prematurely
- Results in fragmented phrases ("我很。" → "Je suis.")
- **Hypothesis**: Silence threshold too aggressive for multi-speaker scenarios
2. **Segments too short for context**
- Whisper receives insufficient audio context for accurate Chinese transcription
- Single-word or two-word segments lack conversational context
- **Impact**: Lower accuracy, especially with homonyms
3. **Ambient noise interpreted as speech**
- Background sounds trigger false VAD positives
- Test transcript shows "太多声音了" (too much noise) being captured
- **Mitigation**: RNNoise helps but not sufficient for very noisy environments
4. **Loss of inter-segment context**
- Each audio chunk processed independently
- Whisper cannot use previous context for better transcription
- **Potential solution**: Pass previous 2-3 transcriptions in prompt
### Test Conditions
Testing has been performed under **deliberately degraded conditions** to ensure robustness:
- Multiple simultaneous speakers
- Variable microphone distance
- Variable volume levels
- Fast-paced conversations
- Low-quality microphone
These conditions are intentionally harsh to validate real-world meeting scenarios.
### Debug Plan
See `PLAN_DEBUG.md` for:
- Detailed session logging implementation (JSON per segment + metadata)
- Improved Whisper prompt engineering
- VAD threshold tuning recommendations
- Context propagation strategies
## Session Logging
### Structure
```
sessions/
└── YYYY-MM-DD_HHMMSS/
├── session.json # Session metadata
├── segments/
│ ├── 001.json # Segment: Chinese + French + metadata
│ ├── 002.json
│ └── ...
└── transcript.txt # Final export
```
### Segment Format
```json
{
"id": 1,
"chinese": "两个老鼠求我",
"french": "Deux souris me supplient"
}
```
**Future enhancements**: Audio duration, RMS levels, timestamps, Whisper/Claude latencies per segment.
## Configuration ## Configuration
@ -295,9 +143,8 @@ sessions/
"chunk_duration_seconds": 10 "chunk_duration_seconds": 10
}, },
"whisper": { "whisper": {
"model": "gpt-4o-mini-transcribe", "model": "whisper-1",
"language": "zh", "language": "zh"
"prompt": "Transcription d'une réunion en chinois mandarin. Plusieurs interlocuteurs. Ne transcris PAS : musique, silence, bruits de fond. Si l'audio est inaudible, renvoie une chaîne vide. Noms possibles: Tingting, Alexis."
}, },
"claude": { "claude": {
"model": "claude-haiku-4-20250514", "model": "claude-haiku-4-20250514",
@ -319,33 +166,23 @@ ANTHROPIC_API_KEY=sk-ant-...
- **Claude Haiku**: ~$0.03-0.05/hour - **Claude Haiku**: ~$0.03-0.05/hour
- **Total**: ~$0.40/hour of recording - **Total**: ~$0.40/hour of recording
## Advanced Features ## Project Structure
### GPU Forcing (Hybrid Graphics Systems) ```
secondvoice/
`main.cpp` exports symbols to force dedicated GPU on Optimus/PowerXpress systems: ├── src/
- `NvOptimusEnablement` - Forces NVIDIA GPU │ ├── main.cpp # Entry point
- `AmdPowerXpressRequestHighPerformance` - Forces AMD GPU │ ├── audio/ # Audio capture & buffer
│ ├── api/ # Whisper & Claude clients
Critical for laptops with both integrated and dedicated GPUs. │ ├── ui/ # ImGui interface
│ ├── utils/ # Config & thread-safe queue
### Hallucination Filtering │ └── core/ # Pipeline orchestration
├── docs/ # Documentation
`Pipeline.cpp` maintains an extensive list (~65 patterns) of known Whisper hallucinations: ├── recordings/ # Output recordings
- YouTube phrases: "Thank you for watching", "Subscribe", "Like and comment" ├── config.json # Runtime configuration
- Chinese video endings: "谢谢观看", "再见", "订阅我的频道" ├── .env # API keys (not committed)
- Music symbols: "♪♪", "🎵" └── CMakeLists.txt # Build configuration
- Silence markers: "...", "silence", "inaudible" ```
These are automatically filtered before translation to avoid wasting API calls.
### Console-Only Build
A `SecondVoice_Console` target exists for headless testing:
- Uses `main_console.cpp`
- No ImGui/GLFW dependencies
- Outputs transcriptions to stdout
- Useful for debugging and automated testing
## Development ## Development
@ -382,101 +219,30 @@ cmake --build build
- Check all system dependencies are installed - Check all system dependencies are installed
- Try `cmake --build build --clean-first` - Try `cmake --build build --clean-first`
## Project Structure
```
secondvoice/
├── src/
│ ├── main.cpp # Entry point, forces NVIDIA GPU
│ ├── core/
│ │ └── Pipeline.cpp # Audio→Transcription→Translation orchestration
│ ├── audio/
│ │ ├── AudioCapture.cpp # PortAudio + VAD segmentation
│ │ ├── AudioBuffer.cpp # Sample accumulation, WAV/Opus export
│ │ └── NoiseReducer.cpp # RNNoise (16→48→16 kHz)
│ ├── api/
│ │ ├── WhisperClient.cpp # OpenAI Whisper (multipart/form-data)
│ │ ├── ClaudeClient.cpp # Anthropic Claude (JSON)
│ │ └── WinHttpClient.cpp # Native Windows HTTP
│ ├── ui/
│ │ └── TranslationUI.cpp # ImGui interface + VAD controls
│ └── utils/
│ ├── Config.cpp # config.json + .env loader
│ └── ThreadSafeQueue.h # Lock-free audio queue
├── docs/ # Build guides
├── sessions/ # Session recordings + logs
├── recordings/ # Legacy recordings directory
├── denoised/ # Denoised audio outputs
├── config.json # Runtime configuration
├── .env # API keys (not committed)
├── CLAUDE.md # Development guide for Claude Code
├── PLAN_DEBUG.md # Active debugging plan
└── CMakeLists.txt # Build configuration
```
### External Dependencies
**Fetched via CMake FetchContent**:
- ImGui v1.90.1 - UI framework
- Opus v1.5.2 - Audio encoding
- Ogg v1.3.6 - Container format
- RNNoise v0.1.1 - Neural network noise reduction
**vcpkg Dependencies** (x64-mingw-static triplet):
- portaudio - Cross-platform audio I/O
- nlohmann_json - JSON parsing
- glfw3 - Windowing/input
- glad - OpenGL loader
## Roadmap ## Roadmap
### Phase 1 - MVP ✅ (Complete) ### Phase 1 - MVP (Current)
- ✅ Audio capture with VAD - ✅ Audio capture
- ✅ Noise reduction (RNNoise) - ✅ Whisper integration
- ✅ Whisper API integration - ✅ Claude integration
- ✅ Claude API integration - ✅ ImGui UI
- ✅ ImGui UI with runtime VAD adjustment - ✅ Stop button
- ✅ Opus compression
- ✅ Hallucination filtering
- ✅ Session recording
### Phase 2 - Debugging 🔄 (Current) ### Phase 2 - Enhancement
- 🔄 Session logging (JSON per segment) - ⬜ Auto-summary post-meeting
- 🔄 Improved Whisper prompt engineering - ⬜ Export transcripts
- 🔄 VAD threshold optimization - ⬜ Search functionality
- 🔄 Context propagation between segments
- ⬜ Automated testing with sample audio
### Phase 3 - Enhancement
- ⬜ Auto-summary post-meeting (Claude analysis)
- ⬜ Full-text search (SQLite FTS5)
- ⬜ Semantic search (embeddings)
- ⬜ Speaker diarization - ⬜ Speaker diarization
- ⬜ Replay mode with synced transcripts - ⬜ Replay mode
- ⬜ Multi-language support extension
## Development Documentation
- **CLAUDE.md** - Development guide for Claude Code AI assistant
- **PLAN_DEBUG.md** - Active debugging plan with identified issues and solutions
- **WINDOWS_BUILD.md** - Detailed Windows build instructions
- **WINDOWS_MINGW.md** - MinGW-specific build guide
- **WINDOWS_QUICK_START.md** - Quick start for Windows users
## Contributing
This is a personal project built to solve a real need. Bug reports and suggestions welcome:
**Known issues**: See `PLAN_DEBUG.md` for current debugging efforts
**Architecture**: See `CLAUDE.md` for detailed system design
## License ## License
See LICENSE file. See LICENSE file.
## Acknowledgments ## Contributing
- OpenAI Whisper for excellent Chinese transcription This is a personal project, but suggestions and bug reports are welcome via issues.
- Anthropic Claude for context-aware translation
- RNNoise for neural network-based noise reduction ## Contact
- ImGui for clean, immediate-mode UI
See docs/SecondVoice.md for project context and motivation.

View File

@ -6,16 +6,11 @@
"chunk_step_seconds": 5, "chunk_step_seconds": 5,
"format": "ogg" "format": "ogg"
}, },
"vad": {
"silence_duration_ms": 700,
"min_speech_duration_ms": 2000,
"max_speech_duration_ms": 30000
},
"whisper": { "whisper": {
"model": "gpt-4o-mini-transcribe", "model": "gpt-4o-mini-transcribe",
"language": "zh", "language": "zh",
"temperature": 0.0, "temperature": 0.0,
"prompt": "Transcription en direct d'une conversation en chinois mandarin. Plusieurs interlocuteurs parlent, parfois en même temps. Si un contexte de phrases précédentes est fourni, utilise-le pour maintenir la cohérence (noms propres, sujets, terminologie). RÈGLES STRICTES: (1) Ne transcris QUE les paroles audibles en chinois. (2) Si l'audio est inaudible, du bruit, ou du silence, renvoie une chaîne vide. (3) NE GÉNÈRE JAMAIS ces phrases: 谢谢观看, 感谢收看, 订阅, 请订阅, 下期再见, Thank you, Subscribe, 字幕. (4) Ignore: musique, applaudissements, rires, bruits de fond, respirations.", "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, "stream": false,
"response_format": "text" "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

@ -4,15 +4,9 @@
namespace secondvoice { namespace secondvoice {
AudioCapture::AudioCapture(int sample_rate, int channels, AudioCapture::AudioCapture(int sample_rate, int channels)
int silence_duration_ms,
int min_speech_duration_ms,
int max_speech_duration_ms)
: sample_rate_(sample_rate) : sample_rate_(sample_rate)
, channels_(channels) , channels_(channels)
, silence_duration_ms_(silence_duration_ms)
, min_speech_duration_ms_(min_speech_duration_ms)
, max_speech_duration_ms_(max_speech_duration_ms)
, noise_reducer_(std::make_unique<NoiseReducer>()) { , noise_reducer_(std::make_unique<NoiseReducer>()) {
std::cout << "[Audio] Noise reduction enabled (RNNoise)" << std::endl; std::cout << "[Audio] Noise reduction enabled (RNNoise)" << std::endl;
} }
@ -49,18 +43,32 @@ int AudioCapture::audioCallback(const void* input, void* output,
const float* in = static_cast<const float*>(input); const float* in = static_cast<const float*>(input);
unsigned long sample_count = frame_count * self->channels_; 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 === // === REAL-TIME DENOISING ===
// Process audio through RNNoise in real-time for meter display // Process audio through RNNoise in real-time for meter display
std::vector<float> denoised_samples; std::vector<float> denoised_samples;
if (self->noise_reducer_ && self->noise_reducer_->isEnabled()) { 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_sum_squared = 0.0f;
float raw_max_amp = 0.0f; float raw_max_amp = 0.0f;
for (unsigned long i = 0; i < sample_count; ++i) { for (unsigned long i = 0; i < sample_count; ++i) {
float sample = in[i]; float sample = amplified_in[i];
raw_sum_squared += sample * sample; raw_sum_squared += sample * sample;
if (std::abs(sample) > raw_max_amp) { if (std::abs(sample) > raw_max_amp) {
raw_max_amp = std::abs(sample); raw_max_amp = std::abs(sample);
@ -141,12 +149,16 @@ int AudioCapture::audioCallback(const void* input, void* output,
// Speech = energy OK AND (ZCR OK or very high energy) // Speech = energy OK AND (ZCR OK or very high energy)
bool frame_has_speech = energy_ok && (zcr_ok || denoised_rms > adaptive_rms_thresh * 3.0f); bool frame_has_speech = energy_ok && (zcr_ok || denoised_rms > adaptive_rms_thresh * 3.0f);
// Reset trailing silence counter when speech detected // Hang time logic: don't immediately cut on silence
if (frame_has_speech) { if (frame_has_speech) {
self->consecutive_silence_frames_ = 0; self->hang_frames_ = self->hang_frames_threshold_; // Reset hang counter
} else if (self->hang_frames_ > 0) {
self->hang_frames_--;
frame_has_speech = true; // Keep "speaking" during hang time
} }
// Calculate durations in samples // Calculate durations in samples
int silence_samples_threshold = (self->silence_duration_ms_ * self->sample_rate_ * self->channels_) / 1000;
int min_speech_samples = (self->min_speech_duration_ms_ * self->sample_rate_ * self->channels_) / 1000; int min_speech_samples = (self->min_speech_duration_ms_ * self->sample_rate_ * self->channels_) / 1000;
int max_speech_samples = (self->max_speech_duration_ms_ * self->sample_rate_ * self->channels_) / 1000; int max_speech_samples = (self->max_speech_duration_ms_ * self->sample_rate_ * self->channels_) / 1000;
@ -160,9 +172,9 @@ int AudioCapture::audioCallback(const void* input, void* output,
self->speech_buffer_.insert(self->speech_buffer_.end(), self->speech_buffer_.insert(self->speech_buffer_.end(),
denoised_samples.begin(), denoised_samples.end()); denoised_samples.begin(), denoised_samples.end());
} else { } else {
// Fallback to raw if denoising disabled // Fallback to amplified if denoising disabled
for (unsigned long i = 0; i < sample_count; ++i) { 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; self->speech_samples_count_ += sample_count;
@ -172,11 +184,6 @@ int AudioCapture::audioCallback(const void* input, void* output,
std::cout << "[VAD] Max duration reached, forcing flush (" std::cout << "[VAD] Max duration reached, forcing flush ("
<< self->speech_samples_count_ / (self->sample_rate_ * self->channels_) << "s)" << std::endl; << self->speech_samples_count_ / (self->sample_rate_ * self->channels_) << "s)" << std::endl;
// Calculate metrics BEFORE flushing
self->last_speech_duration_ms_ = (self->speech_samples_count_ * 1000) / (self->sample_rate_ * self->channels_);
self->last_silence_duration_ms_ = 0; // No trailing silence in forced flush
self->last_flush_reason_ = "max_duration";
if (self->callback_ && self->speech_buffer_.size() >= static_cast<size_t>(min_speech_samples)) { if (self->callback_ && self->speech_buffer_.size() >= static_cast<size_t>(min_speech_samples)) {
// Flush any remaining samples from the denoiser // Flush any remaining samples from the denoiser
if (self->noise_reducer_ && self->noise_reducer_->isEnabled()) { if (self->noise_reducer_ && self->noise_reducer_->isEnabled()) {
@ -190,45 +197,30 @@ int AudioCapture::audioCallback(const void* input, void* output,
} }
self->speech_buffer_.clear(); self->speech_buffer_.clear();
self->speech_samples_count_ = 0; self->speech_samples_count_ = 0;
self->consecutive_silence_frames_ = 0; // Reset after forced flush
// Reset stream for next segment // Reset stream for next segment
if (self->noise_reducer_) { if (self->noise_reducer_) {
self->noise_reducer_->resetStream(); self->noise_reducer_->resetStream();
} }
} }
} else { } else {
// Silence detected // True silence (after hang time expired)
self->silence_samples_count_ += sample_count; self->silence_samples_count_ += sample_count;
// If we were speaking and now have silence, track consecutive silence frames // If we were speaking and now have enough silence, flush
if (self->speech_buffer_.size() > 0) { if (self->speech_buffer_.size() > 0) {
// Add trailing silence (denoised) // Add trailing silence (denoised or amplified)
if (!denoised_samples.empty()) { if (!denoised_samples.empty()) {
self->speech_buffer_.insert(self->speech_buffer_.end(), self->speech_buffer_.insert(self->speech_buffer_.end(),
denoised_samples.begin(), denoised_samples.end()); denoised_samples.begin(), denoised_samples.end());
} else { } else {
for (unsigned long i = 0; i < sample_count; ++i) { for (unsigned long i = 0; i < sample_count; ++i) {
self->speech_buffer_.push_back(in[i]); self->speech_buffer_.push_back(amplified_in[i]);
} }
} }
// Increment consecutive silence frame counter if (self->silence_samples_count_ >= silence_samples_threshold) {
self->consecutive_silence_frames_++;
// Calculate threshold in frames (callbacks)
// frames_per_buffer = frame_count from callback
int frames_per_buffer = static_cast<int>(frame_count);
int silence_threshold_frames = (self->silence_duration_ms_ * self->sample_rate_) / (1000 * frames_per_buffer);
// Flush when consecutive silence exceeds threshold
if (self->consecutive_silence_frames_ >= silence_threshold_frames) {
self->is_speech_active_.store(false, std::memory_order_relaxed); self->is_speech_active_.store(false, std::memory_order_relaxed);
// Calculate metrics BEFORE flushing
self->last_speech_duration_ms_ = (self->speech_samples_count_ * 1000) / (self->sample_rate_ * self->channels_);
self->last_silence_duration_ms_ = (self->silence_samples_count_ * 1000) / (self->sample_rate_ * self->channels_);
self->last_flush_reason_ = "silence_threshold";
// Flush if we have enough speech // Flush if we have enough speech
if (self->speech_samples_count_ >= min_speech_samples) { if (self->speech_samples_count_ >= min_speech_samples) {
// Flush any remaining samples from the denoiser // Flush any remaining samples from the denoiser
@ -242,9 +234,7 @@ int AudioCapture::audioCallback(const void* input, void* output,
float duration = static_cast<float>(self->speech_buffer_.size()) / float duration = static_cast<float>(self->speech_buffer_.size()) /
(self->sample_rate_ * self->channels_); (self->sample_rate_ * self->channels_);
std::cout << "[VAD] Speech ended (trailing silence detected, " std::cout << "[VAD] Speech ended (noise_floor=" << self->noise_floor_
<< self->consecutive_silence_frames_ << " frames, "
<< "noise_floor=" << self->noise_floor_
<< "), flushing " << duration << "s (denoised)" << std::endl; << "), flushing " << duration << "s (denoised)" << std::endl;
if (self->callback_) { if (self->callback_) {
@ -257,7 +247,6 @@ int AudioCapture::audioCallback(const void* input, void* output,
self->speech_buffer_.clear(); self->speech_buffer_.clear();
self->speech_samples_count_ = 0; self->speech_samples_count_ = 0;
self->consecutive_silence_frames_ = 0; // Reset after flush
// Reset stream for next segment // Reset stream for next segment
if (self->noise_reducer_) { if (self->noise_reducer_) {
self->noise_reducer_->resetStream(); self->noise_reducer_->resetStream();

View File

@ -16,10 +16,7 @@ class AudioCapture {
public: public:
using AudioCallback = std::function<void(const std::vector<float>&)>; using AudioCallback = std::function<void(const std::vector<float>&)>;
AudioCapture(int sample_rate, int channels, AudioCapture(int sample_rate, int channels);
int silence_duration_ms = 700,
int min_speech_duration_ms = 2000,
int max_speech_duration_ms = 30000);
~AudioCapture(); ~AudioCapture();
bool initialize(); bool initialize();
@ -36,6 +33,10 @@ public:
vad_rms_threshold_ = rms_threshold; vad_rms_threshold_ = rms_threshold;
vad_peak_threshold_ = peak_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 setSilenceDuration(int ms) { silence_duration_ms_ = ms; }
void setMinSpeechDuration(int ms) { min_speech_duration_ms_ = ms; } void setMinSpeechDuration(int ms) { min_speech_duration_ms_ = ms; }
void setMaxSpeechDuration(int ms) { max_speech_duration_ms_ = ms; } void setMaxSpeechDuration(int ms) { max_speech_duration_ms_ = ms; }
@ -47,11 +48,6 @@ public:
void setDenoiseEnabled(bool enabled); void setDenoiseEnabled(bool enabled);
bool isDenoiseEnabled() const; bool isDenoiseEnabled() const;
// Get metrics from last flushed segment
int getLastSpeechDuration() const { return last_speech_duration_ms_; }
int getLastSilenceDuration() const { return last_silence_duration_ms_; }
std::string getLastFlushReason() const { return last_flush_reason_; }
private: private:
static int audioCallback(const void* input, void* output, static int audioCallback(const void* input, void* output,
unsigned long frame_count, unsigned long frame_count,
@ -77,21 +73,17 @@ private:
// VAD parameters - Higher threshold to avoid false triggers on filtered noise // VAD parameters - Higher threshold to avoid false triggers on filtered noise
std::atomic<float> vad_rms_threshold_{0.02f}; // Was 0.01f std::atomic<float> vad_rms_threshold_{0.02f}; // Was 0.01f
std::atomic<float> vad_peak_threshold_{0.08f}; // Was 0.04f std::atomic<float> vad_peak_threshold_{0.08f}; // Was 0.04f
int silence_duration_ms_; // Wait 700ms of silence before cutting (was 400) int silence_duration_ms_ = 400; // Wait 400ms of silence before cutting
int min_speech_duration_ms_; // Minimum 2s speech to send (was 1000) int min_speech_duration_ms_ = 300; // Minimum speech to send
int max_speech_duration_ms_; // 30s max before forced flush (was 25000) int max_speech_duration_ms_ = 25000; // 25s max before forced flush
// Adaptive noise floor // Adaptive noise floor
float noise_floor_ = 0.005f; // Estimated background noise level float noise_floor_ = 0.005f; // Estimated background noise level
float noise_floor_alpha_ = 0.001f; // Slower adaptation float noise_floor_alpha_ = 0.001f; // Slower adaptation
// Trailing silence detection - count consecutive silence frames after speech // Hang time - wait before cutting to avoid mid-sentence cuts
int consecutive_silence_frames_ = 0; int hang_frames_ = 0;
int hang_frames_threshold_ = 20; // ~200ms tolerance for pauses
// Metrics for last flushed segment (set in callback, read in processing thread)
int last_speech_duration_ms_ = 0;
int last_silence_duration_ms_ = 0;
std::string last_flush_reason_;
// Zero-crossing rate for speech vs noise discrimination // Zero-crossing rate for speech vs noise discrimination
float last_zcr_ = 0.0f; float last_zcr_ = 0.0f;
@ -102,6 +94,9 @@ private:
std::atomic<float> current_rms_{0.0f}; std::atomic<float> current_rms_{0.0f};
std::atomic<float> current_peak_{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 // Noise reduction
std::unique_ptr<NoiseReducer> noise_reducer_; std::unique_ptr<NoiseReducer> noise_reducer_;
}; };

View File

@ -5,6 +5,7 @@
#include "../api/ClaudeClient.h" #include "../api/ClaudeClient.h"
#include "../ui/TranslationUI.h" #include "../ui/TranslationUI.h"
#include "../utils/Config.h" #include "../utils/Config.h"
#include "../utils/SessionLogger.h"
#include <iostream> #include <iostream>
#include <iomanip> #include <iomanip>
#include <sstream> #include <sstream>
@ -24,23 +25,12 @@ Pipeline::~Pipeline() {
bool Pipeline::initialize() { bool Pipeline::initialize() {
auto& config = Config::getInstance(); auto& config = Config::getInstance();
// Load VAD parameters from config (with fallbacks if missing)
int silence_duration = config.getVadSilenceDurationMs();
int min_speech = config.getVadMinSpeechDurationMs();
int max_speech = config.getVadMaxSpeechDurationMs();
// Initialize audio capture with VAD-based segmentation // Initialize audio capture with VAD-based segmentation
audio_capture_ = std::make_unique<AudioCapture>( audio_capture_ = std::make_unique<AudioCapture>(
config.getAudioConfig().sample_rate, config.getAudioConfig().sample_rate,
config.getAudioConfig().channels, config.getAudioConfig().channels
silence_duration,
min_speech,
max_speech
); );
std::cout << "[Pipeline] VAD configured: silence=" << silence_duration
<< "ms, min_speech=" << min_speech
<< "ms, max_speech=" << max_speech << "ms" << std::endl;
std::cout << "[Pipeline] VAD-based audio segmentation enabled" << std::endl; std::cout << "[Pipeline] VAD-based audio segmentation enabled" << std::endl;
if (!audio_capture_->initialize()) { if (!audio_capture_->initialize()) {
@ -72,6 +62,10 @@ bool Pipeline::initialize() {
// Create recordings directory if it doesn't exist // Create recordings directory if it doesn't exist
std::filesystem::create_directories(config.getRecordingConfig().output_directory); 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; return true;
} }
@ -81,10 +75,15 @@ bool Pipeline::start() {
} }
running_ = true; running_ = true;
segment_id_ = 0;
// Start session logging // Start session logging
session_logger_.startSession(); if (session_logger_) {
session_logger_->startSession();
session_logger_->setVadSettings(
ui_ ? ui_->getVadThreshold() : 0.02f,
ui_ ? ui_->getVadPeakThreshold() : 0.08f
);
}
// Start background threads // Start background threads
audio_thread_ = std::thread(&Pipeline::audioThread, this); audio_thread_ = std::thread(&Pipeline::audioThread, this);
@ -143,7 +142,9 @@ void Pipeline::stop() {
} }
// End session logging // End session logging
session_logger_.endSession(); if (session_logger_) {
session_logger_->endSession();
}
} }
void Pipeline::audioThread() { void Pipeline::audioThread() {
@ -155,14 +156,26 @@ void Pipeline::audioThread() {
// Add to full recording // Add to full recording
full_recording_->addSamples(audio_data); 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; AudioChunk chunk;
chunk.data = audio_data; chunk.data = audio_data;
chunk.sample_rate = config.getAudioConfig().sample_rate; chunk.sample_rate = config.getAudioConfig().sample_rate;
chunk.channels = config.getAudioConfig().channels; chunk.channels = config.getAudioConfig().channels;
chunk.rms_level = rms;
chunk.peak_level = max_amp;
chunk.timestamp = std::chrono::system_clock::now();
float push_duration = static_cast<float>(audio_data.size()) / (chunk.sample_rate * chunk.channels);
std::cout << "[Queue] Pushing " << push_duration << "s chunk, queue size: " << audio_queue_.size() << std::endl;
audio_queue_.push(std::move(chunk)); audio_queue_.push(std::move(chunk));
}); });
@ -179,7 +192,6 @@ void Pipeline::audioThread() {
void Pipeline::processingThread() { void Pipeline::processingThread() {
auto& config = Config::getInstance(); auto& config = Config::getInstance();
int audio_segment_id = 0;
while (running_) { while (running_) {
auto chunk_opt = audio_queue_.wait_and_pop(); auto chunk_opt = audio_queue_.wait_and_pop();
@ -189,44 +201,38 @@ void Pipeline::processingThread() {
auto& chunk = chunk_opt.value(); auto& chunk = chunk_opt.value();
float duration = static_cast<float>(chunk.data.size()) / (chunk.sample_rate * chunk.channels); float duration = static_cast<float>(chunk.data.size()) / (chunk.sample_rate * chunk.channels);
std::cout << "[Processing] Speech segment: " << duration << "s" << std::endl;
// Debug: log queue size to detect double-push // Prepare segment data for logging
std::cout << "[Queue] Processing chunk, " << audio_queue_.size() << " remaining" << std::endl; 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 segment to session directory for debugging // Save audio for this segment (also calculates hashes for duplicate detection)
audio_segment_id++; if (session_logger_) {
if (session_logger_.isActive()) { segment.audio_filename = session_logger_->saveSegmentAudio(
std::stringstream audio_path; segment.id, chunk.data, chunk.sample_rate, chunk.channels,
audio_path << session_logger_.getSessionPath() << "/audio_" segment.audio_hashes);
<< std::setfill('0') << std::setw(3) << audio_segment_id << ".ogg";
AudioBuffer segment_buffer(chunk.sample_rate, chunk.channels);
segment_buffer.addSamples(chunk.data);
if (segment_buffer.saveToOpus(audio_path.str())) {
std::cout << "[Session] Saved audio segment: " << audio_path.str() << std::endl;
} }
}
// Calculate audio RMS for logging
float audio_rms = 0.0f;
if (!chunk.data.empty()) {
float sum_sq = 0.0f;
for (float s : chunk.data) sum_sq += s * s;
audio_rms = std::sqrt(sum_sq / chunk.data.size());
}
std::cout << "[Processing] Speech segment: " << duration << "s (RMS=" << audio_rms << ")" << std::endl;
// Time Whisper
auto whisper_start = std::chrono::steady_clock::now();
// Build dynamic prompt with recent context // Build dynamic prompt with recent context
std::string dynamic_prompt = buildDynamicPrompt(); std::string whisper_prompt = config.getWhisperConfig().prompt;
if (!recent_transcriptions_.empty()) { if (session_logger_) {
std::cout << "[Context] Using " << recent_transcriptions_.size() << " previous segments" << std::endl; 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 // Transcribe with Whisper (measure latency)
auto whisper_start = std::chrono::steady_clock::now();
auto whisper_result = whisper_client_->transcribe( auto whisper_result = whisper_client_->transcribe(
chunk.data, chunk.data,
chunk.sample_rate, chunk.sample_rate,
@ -234,17 +240,18 @@ void Pipeline::processingThread() {
config.getWhisperConfig().model, config.getWhisperConfig().model,
config.getWhisperConfig().language, config.getWhisperConfig().language,
config.getWhisperConfig().temperature, config.getWhisperConfig().temperature,
dynamic_prompt, whisper_prompt,
config.getWhisperConfig().response_format config.getWhisperConfig().response_format
); );
auto whisper_end = std::chrono::steady_clock::now(); auto whisper_end = std::chrono::steady_clock::now();
int64_t whisper_latency = std::chrono::duration_cast<std::chrono::milliseconds>( segment.whisper_latency_ms = std::chrono::duration<float, std::milli>(whisper_end - whisper_start).count();
whisper_end - whisper_start).count();
if (!whisper_result.has_value()) { if (!whisper_result.has_value()) {
std::cerr << "Whisper transcription failed" << std::endl; std::cerr << "Whisper transcription failed" << std::endl;
session_logger_.logFilteredSegment("", "whisper_failed", duration, audio_rms); 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; continue;
} }
@ -256,7 +263,10 @@ void Pipeline::processingThread() {
size_t end = text.find_last_not_of(" \t\n\r"); size_t end = text.find_last_not_of(" \t\n\r");
if (start == std::string::npos) { if (start == std::string::npos) {
std::cout << "[Skip] Empty transcription" << std::endl; std::cout << "[Skip] Empty transcription" << std::endl;
session_logger_.logFilteredSegment("", "empty", duration, audio_rms); segment.was_filtered = true;
segment.filter_reason = "empty";
segment.end_time = std::chrono::system_clock::now();
if (session_logger_) session_logger_->logSegment(segment);
continue; continue;
} }
text = text.substr(start, end - start + 1); text = text.substr(start, end - start + 1);
@ -329,47 +339,37 @@ void Pipeline::processingThread() {
if (is_garbage) { if (is_garbage) {
std::cout << "[Skip] Filtered: " << text << std::endl; std::cout << "[Skip] Filtered: " << text << std::endl;
session_logger_.logFilteredSegment(text, "hallucination", duration, audio_rms); 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; continue;
} }
// Deduplication: skip if exact same as last transcription
if (text == last_transcription_) {
std::cout << "[Skip] Duplicate: " << text << std::endl;
session_logger_.logFilteredSegment(text, "duplicate", duration, audio_rms);
continue;
}
last_transcription_ = text;
// Update dynamic context for next Whisper call
recent_transcriptions_.push_back(text);
if (recent_transcriptions_.size() > MAX_CONTEXT_SEGMENTS) {
recent_transcriptions_.erase(recent_transcriptions_.begin());
}
// Track audio cost // Track audio cost
if (ui_) { if (ui_) {
ui_->addAudioCost(duration); ui_->addAudioCost(duration);
} }
// Time Claude // Translate with Claude (measure latency)
auto claude_start = std::chrono::steady_clock::now(); auto claude_start = std::chrono::steady_clock::now();
// Translate with Claude
auto claude_result = claude_client_->translate( auto claude_result = claude_client_->translate(
text, text,
config.getClaudeConfig().system_prompt, config.getClaudeConfig().system_prompt,
config.getClaudeConfig().max_tokens, config.getClaudeConfig().max_tokens,
config.getClaudeConfig().temperature config.getClaudeConfig().temperature
); );
auto claude_end = std::chrono::steady_clock::now(); auto claude_end = std::chrono::steady_clock::now();
int64_t claude_latency = std::chrono::duration_cast<std::chrono::milliseconds>( segment.claude_latency_ms = std::chrono::duration<float, std::milli>(claude_end - claude_start).count();
claude_end - claude_start).count();
if (!claude_result.has_value()) { if (!claude_result.has_value()) {
std::cerr << "Claude translation failed" << std::endl; std::cerr << "Claude translation failed" << std::endl;
session_logger_.logFilteredSegment(text, "claude_failed", duration, audio_rms); 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; continue;
} }
@ -378,6 +378,14 @@ void Pipeline::processingThread() {
ui_->addClaudeCost(); 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 // Simple accumulation
if (!accumulated_chinese_.empty()) { if (!accumulated_chinese_.empty()) {
accumulated_chinese_ += " "; accumulated_chinese_ += " ";
@ -393,28 +401,8 @@ void Pipeline::processingThread() {
ui_->setAccumulatedText(accumulated_chinese_, accumulated_french_); ui_->setAccumulatedText(accumulated_chinese_, accumulated_french_);
ui_->addTranslation(text, claude_result->text); ui_->addTranslation(text, claude_result->text);
// Log successful segment
segment_id_++;
SegmentLog seg;
seg.id = segment_id_;
seg.chinese = text;
seg.french = claude_result->text;
seg.audio_duration_sec = duration;
seg.audio_rms = audio_rms;
seg.whisper_latency_ms = whisper_latency;
seg.claude_latency_ms = claude_latency;
seg.was_filtered = false;
seg.filter_reason = "";
seg.timestamp = ""; // Will be set by logger
// Add VAD metrics from AudioCapture
seg.speech_duration_ms = audio_capture_->getLastSpeechDuration();
seg.silence_duration_ms = audio_capture_->getLastSilenceDuration();
seg.flush_reason = audio_capture_->getLastFlushReason();
session_logger_.logSegment(seg);
std::cout << "CN: " << text << std::endl; std::cout << "CN: " << text << std::endl;
std::cout << "FR: " << claude_result->text << std::endl; std::cout << "FR: " << claude_result->text << std::endl;
std::cout << "[Latency] Whisper: " << whisper_latency << "ms, Claude: " << claude_latency << "ms" << std::endl;
std::cout << "---" << std::endl; std::cout << "---" << std::endl;
} }
} }
@ -422,12 +410,13 @@ void Pipeline::processingThread() {
void Pipeline::update() { void Pipeline::update() {
if (!ui_) return; if (!ui_) return;
// Sync VAD thresholds from UI to AudioCapture // Sync VAD thresholds and input gain from UI to AudioCapture
if (audio_capture_) { if (audio_capture_) {
audio_capture_->setVadThresholds( audio_capture_->setVadThresholds(
ui_->getVadThreshold(), ui_->getVadThreshold(),
ui_->getVadPeakThreshold() ui_->getVadPeakThreshold()
); );
audio_capture_->setInputGain(ui_->getInputGain());
// Update UI with audio levels // Update UI with audio levels
ui_->setCurrentRMS(audio_capture_->getCurrentRMS()); ui_->setCurrentRMS(audio_capture_->getCurrentRMS());
@ -463,34 +452,10 @@ bool Pipeline::shouldClose() const {
void Pipeline::clearAccumulated() { void Pipeline::clearAccumulated() {
accumulated_chinese_.clear(); accumulated_chinese_.clear();
accumulated_french_.clear(); accumulated_french_.clear();
recent_transcriptions_.clear();
last_transcription_.clear();
if (ui_) { if (ui_) {
ui_->setAccumulatedText("", ""); ui_->setAccumulatedText("", "");
} }
std::cout << "[Pipeline] Cleared accumulated text and context" << std::endl; std::cout << "[Pipeline] Cleared accumulated text" << std::endl;
}
std::string Pipeline::buildDynamicPrompt() const {
auto& config = Config::getInstance();
std::string base_prompt = config.getWhisperConfig().prompt;
// If no recent transcriptions, just return base prompt
if (recent_transcriptions_.empty()) {
return base_prompt;
}
// Build context from recent transcriptions
std::stringstream context;
context << base_prompt;
context << "\n\nContexte des phrases précédentes:\n";
for (size_t i = 0; i < recent_transcriptions_.size(); ++i) {
context << std::to_string(i + 1) << ". "
<< recent_transcriptions_[i] << "\n";
}
return context.str();
} }
} // namespace secondvoice } // namespace secondvoice

View File

@ -5,8 +5,8 @@
#include <atomic> #include <atomic>
#include <string> #include <string>
#include <vector> #include <vector>
#include <chrono>
#include "../utils/ThreadSafeQueue.h" #include "../utils/ThreadSafeQueue.h"
#include "../utils/SessionLogger.h"
namespace secondvoice { namespace secondvoice {
@ -15,11 +15,15 @@ class WhisperClient;
class ClaudeClient; class ClaudeClient;
class TranslationUI; class TranslationUI;
class AudioBuffer; class AudioBuffer;
class SessionLogger;
struct AudioChunk { struct AudioChunk {
std::vector<float> data; std::vector<float> data;
int sample_rate; int sample_rate;
int channels; int channels;
float rms_level;
float peak_level;
std::chrono::system_clock::time_point timestamp;
}; };
class Pipeline { class Pipeline {
@ -49,6 +53,7 @@ private:
std::unique_ptr<ClaudeClient> claude_client_; std::unique_ptr<ClaudeClient> claude_client_;
std::unique_ptr<TranslationUI> ui_; std::unique_ptr<TranslationUI> ui_;
std::unique_ptr<AudioBuffer> full_recording_; std::unique_ptr<AudioBuffer> full_recording_;
std::unique_ptr<SessionLogger> session_logger_;
ThreadSafeQueue<AudioChunk> audio_queue_; ThreadSafeQueue<AudioChunk> audio_queue_;
@ -61,20 +66,6 @@ private:
// Simple accumulation // Simple accumulation
std::string accumulated_chinese_; std::string accumulated_chinese_;
std::string accumulated_french_; std::string accumulated_french_;
// Dynamic context for Whisper (last N transcriptions)
std::vector<std::string> recent_transcriptions_;
static constexpr size_t MAX_CONTEXT_SEGMENTS = 3;
// Deduplication: skip if same as last transcription
std::string last_transcription_;
// Build dynamic prompt with recent context
std::string buildDynamicPrompt() const;
// Session logging
SessionLogger session_logger_;
int segment_id_ = 0;
}; };
} // namespace secondvoice } // namespace secondvoice

View File

@ -400,6 +400,17 @@ void TranslationUI::renderAudioPanel() {
ImGui::Separator(); ImGui::Separator();
ImGui::Spacing(); 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 // VAD Threshold sliders
ImGui::Text("VAD Settings"); ImGui::Text("VAD Settings");
ImGui::Spacing(); ImGui::Spacing();

View File

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

View File

@ -52,9 +52,10 @@ bool Config::load(const std::string& config_path, const std::string& env_path) {
} }
std::cerr << "[Config] File opened successfully" << std::endl; std::cerr << "[Config] File opened successfully" << std::endl;
json config_json;
try { try {
std::cerr << "[Config] About to parse JSON..." << std::endl; std::cerr << "[Config] About to parse JSON..." << std::endl;
config_file >> config_; config_file >> config_json;
std::cerr << "[Config] JSON parsed successfully" << std::endl; std::cerr << "[Config] JSON parsed successfully" << std::endl;
} catch (const json::parse_error& e) { } catch (const json::parse_error& e) {
std::cerr << "Error parsing config.json: " << e.what() << std::endl; std::cerr << "Error parsing config.json: " << e.what() << std::endl;
@ -65,8 +66,8 @@ bool Config::load(const std::string& config_path, const std::string& env_path) {
} }
// Parse audio config // Parse audio config
if (config_.contains("audio")) { if (config_json.contains("audio")) {
auto& audio = config_["audio"]; auto& audio = config_json["audio"];
audio_config_.sample_rate = audio.value("sample_rate", 16000); audio_config_.sample_rate = audio.value("sample_rate", 16000);
audio_config_.channels = audio.value("channels", 1); audio_config_.channels = audio.value("channels", 1);
audio_config_.chunk_duration_seconds = audio.value("chunk_duration_seconds", 10); audio_config_.chunk_duration_seconds = audio.value("chunk_duration_seconds", 10);
@ -75,8 +76,8 @@ bool Config::load(const std::string& config_path, const std::string& env_path) {
} }
// Parse whisper config // Parse whisper config
if (config_.contains("whisper")) { if (config_json.contains("whisper")) {
auto& whisper = config_["whisper"]; auto& whisper = config_json["whisper"];
whisper_config_.model = whisper.value("model", "whisper-1"); whisper_config_.model = whisper.value("model", "whisper-1");
whisper_config_.language = whisper.value("language", "zh"); whisper_config_.language = whisper.value("language", "zh");
whisper_config_.temperature = whisper.value("temperature", 0.0f); whisper_config_.temperature = whisper.value("temperature", 0.0f);
@ -86,8 +87,8 @@ bool Config::load(const std::string& config_path, const std::string& env_path) {
} }
// Parse claude config // Parse claude config
if (config_.contains("claude")) { if (config_json.contains("claude")) {
auto& claude = config_["claude"]; auto& claude = config_json["claude"];
claude_config_.model = claude.value("model", "claude-haiku-4-20250514"); claude_config_.model = claude.value("model", "claude-haiku-4-20250514");
claude_config_.max_tokens = claude.value("max_tokens", 1024); claude_config_.max_tokens = claude.value("max_tokens", 1024);
claude_config_.temperature = claude.value("temperature", 0.3f); claude_config_.temperature = claude.value("temperature", 0.3f);
@ -95,8 +96,8 @@ bool Config::load(const std::string& config_path, const std::string& env_path) {
} }
// Parse UI config // Parse UI config
if (config_.contains("ui")) { if (config_json.contains("ui")) {
auto& ui = config_["ui"]; auto& ui = config_json["ui"];
ui_config_.window_width = ui.value("window_width", 800); ui_config_.window_width = ui.value("window_width", 800);
ui_config_.window_height = ui.value("window_height", 600); ui_config_.window_height = ui.value("window_height", 600);
ui_config_.font_size = ui.value("font_size", 16); ui_config_.font_size = ui.value("font_size", 16);
@ -104,8 +105,8 @@ bool Config::load(const std::string& config_path, const std::string& env_path) {
} }
// Parse recording config // Parse recording config
if (config_.contains("recording")) { if (config_json.contains("recording")) {
auto& recording = config_["recording"]; auto& recording = config_json["recording"];
recording_config_.save_audio = recording.value("save_audio", true); recording_config_.save_audio = recording.value("save_audio", true);
recording_config_.output_directory = recording.value("output_directory", "./recordings"); recording_config_.output_directory = recording.value("output_directory", "./recordings");
} }
@ -113,25 +114,4 @@ bool Config::load(const std::string& config_path, const std::string& env_path) {
return true; return true;
} }
int Config::getVadSilenceDurationMs() const {
if (config_.contains("vad") && config_["vad"].contains("silence_duration_ms")) {
return config_["vad"]["silence_duration_ms"].get<int>();
}
return 700; // Default from AudioCapture.h:72 (unchanged)
}
int Config::getVadMinSpeechDurationMs() const {
if (config_.contains("vad") && config_["vad"].contains("min_speech_duration_ms")) {
return config_["vad"]["min_speech_duration_ms"].get<int>();
}
return 2000; // Default from AudioCapture.h:73 (updated in TASK2)
}
int Config::getVadMaxSpeechDurationMs() const {
if (config_.contains("vad") && config_["vad"].contains("max_speech_duration_ms")) {
return config_["vad"]["max_speech_duration_ms"].get<int>();
}
return 30000; // Default from AudioCapture.h:74 (updated in TASK2)
}
} // namespace secondvoice } // namespace secondvoice

View File

@ -1,7 +1,6 @@
#pragma once #pragma once
#include <string> #include <string>
#include <nlohmann/json.hpp>
namespace secondvoice { namespace secondvoice {
@ -56,10 +55,6 @@ public:
const std::string& getOpenAIKey() const { return openai_key_; } const std::string& getOpenAIKey() const { return openai_key_; }
const std::string& getAnthropicKey() const { return anthropic_key_; } const std::string& getAnthropicKey() const { return anthropic_key_; }
int getVadSilenceDurationMs() const;
int getVadMinSpeechDurationMs() const;
int getVadMaxSpeechDurationMs() const;
private: private:
Config() = default; Config() = default;
Config(const Config&) = delete; Config(const Config&) = delete;
@ -73,7 +68,6 @@ private:
std::string openai_key_; std::string openai_key_;
std::string anthropic_key_; std::string anthropic_key_;
nlohmann::json config_;
}; };
} // namespace secondvoice } // namespace secondvoice

View File

@ -1,201 +1,412 @@
#include "SessionLogger.h" #include "SessionLogger.h"
#include <nlohmann/json.hpp>
#include <filesystem>
#include <iostream> #include <iostream>
#include <iomanip> #include <iomanip>
#include <sstream> #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 { namespace secondvoice {
using json = nlohmann::json; SessionLogger::SessionLogger() {
metadata_ = {};
SessionLogger::SessionLogger() = default; std::srand(static_cast<unsigned int>(std::time(nullptr))); // For OGG stream IDs
}
SessionLogger::~SessionLogger() { SessionLogger::~SessionLogger() {
if (is_active_) { if (session_active_) {
endSession(); endSession();
} }
} }
std::string SessionLogger::getCurrentTimestamp() const { bool SessionLogger::startSession() {
std::lock_guard<std::mutex> lock(mutex_);
// Generate session ID from timestamp
auto now = std::chrono::system_clock::now(); auto now = std::chrono::system_clock::now();
auto time_t = std::chrono::system_clock::to_time_t(now); auto time_t = std::chrono::system_clock::to_time_t(now);
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch()) % 1000;
std::stringstream ss; std::stringstream ss;
ss << std::put_time(std::localtime(&time_t), "%Y-%m-%d_%H%M%S"); ss << std::put_time(std::localtime(&time_t), "%Y-%m-%d_%H%M%S");
return ss.str(); metadata_.session_id = ss.str();
} metadata_.start_time = now;
void SessionLogger::startSession() { // Create directory structure: sessions/YYYY-MM-DD_HHMMSS/
if (is_active_) { session_path_ = "sessions/" + metadata_.session_id;
endSession(); 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;
} }
session_start_time_ = getCurrentTimestamp(); // Initialize metadata
session_path_ = "./sessions/" + session_start_time_; metadata_.total_segments = 0;
metadata_.filtered_segments = 0;
metadata_.total_audio_seconds = 0.0f;
metadata_.total_cost_estimate = 0.0f;
// Create directories next_segment_id_ = 1;
std::filesystem::create_directories(session_path_ + "/segments");
is_active_ = true;
segment_count_ = 0;
filtered_count_ = 0;
total_audio_sec_ = 0.0f;
total_whisper_ms_ = 0;
total_claude_ms_ = 0;
segments_.clear(); segments_.clear();
session_active_ = true;
std::cout << "[Session] Started: " << session_path_ << std::endl; std::cout << "[SessionLogger] Started session: " << metadata_.session_id << std::endl;
return true;
} }
void SessionLogger::endSession() { void SessionLogger::endSession() {
if (!is_active_) return; 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(); writeSessionJson();
is_active_ = false;
std::cout << "[Session] Ended: " << segment_count_ << " segments, " session_active_ = false;
<< filtered_count_ << " filtered, " std::cout << "[SessionLogger] Ended session: " << metadata_.session_id
<< total_audio_sec_ << "s audio" << std::endl; << " (" << metadata_.total_segments << " segments)" << std::endl;
} }
void SessionLogger::logSegment(const SegmentLog& segment) { void SessionLogger::logSegment(const SegmentData& segment) {
if (!is_active_) return; std::lock_guard<std::mutex> lock(mutex_);
// Update counters if (!session_active_) return;
segment_count_++;
total_audio_sec_ += segment.audio_duration_sec;
total_whisper_ms_ += segment.whisper_latency_ms;
total_claude_ms_ += segment.claude_latency_ms;
// Store segment
segments_.push_back(segment); segments_.push_back(segment);
next_segment_id_++; // Increment for next segment
// Write individual segment JSON if (segment.was_filtered) {
std::stringstream filename; metadata_.filtered_segments++;
filename << session_path_ << "/segments/"
<< std::setfill('0') << std::setw(3) << segment.id << ".json";
json j;
j["id"] = segment.id;
j["chinese"] = segment.chinese;
j["french"] = segment.french;
j["audio_duration_sec"] = segment.audio_duration_sec;
j["audio_rms"] = segment.audio_rms;
j["whisper_latency_ms"] = segment.whisper_latency_ms;
j["claude_latency_ms"] = segment.claude_latency_ms;
j["was_filtered"] = segment.was_filtered;
j["filter_reason"] = segment.filter_reason;
j["timestamp"] = segment.timestamp;
j["vad_metrics"] = {
{"speech_duration_ms", segment.speech_duration_ms},
{"silence_duration_ms", segment.silence_duration_ms},
{"flush_reason", segment.flush_reason}
};
std::ofstream file(filename.str());
if (file.is_open()) {
file << j.dump(2);
file.close();
} }
metadata_.total_audio_seconds += segment.duration_seconds;
std::cout << "[Session] Logged segment #" << segment.id // Estimate cost: Whisper $0.006/min, Claude ~$0.001/call
<< " (" << segment.audio_duration_sec << "s)" << std::endl; 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);
} }
void SessionLogger::logFilteredSegment(const std::string& chinese, const std::string& reason, std::string SessionLogger::saveSegmentAudio(int segment_id, const std::vector<float>& audio_data,
float audio_duration, float audio_rms) { int sample_rate, int channels,
if (!is_active_) return; std::vector<std::string>& out_hashes) {
std::lock_guard<std::mutex> lock(mutex_);
filtered_count_++; out_hashes.clear();
total_audio_sec_ += audio_duration;
// Log filtered segment with special marker if (!session_active_) return "";
SegmentLog seg;
seg.id = segment_count_ + filtered_count_;
seg.chinese = chinese;
seg.french = "[FILTERED]";
seg.audio_duration_sec = audio_duration;
seg.audio_rms = audio_rms;
seg.whisper_latency_ms = 0;
seg.claude_latency_ms = 0;
seg.was_filtered = true;
seg.filter_reason = reason;
seg.timestamp = getCurrentTimestamp();
segments_.push_back(seg); // 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;
// Write filtered segment JSON for (size_t sec = 0; sec < num_seconds; ++sec) {
std::stringstream filename; size_t start = sec * samples_per_second;
filename << session_path_ << "/segments/" size_t end = std::min(start + samples_per_second, audio_data.size());
<< std::setfill('0') << std::setw(3) << seg.id << "_filtered.json"; size_t count = end - start;
json j; uint64_t hash = fnv1a_hash(audio_data.data() + start, count);
j["id"] = seg.id; out_hashes.push_back(hash_to_hex(hash));
j["chinese"] = seg.chinese;
j["filter_reason"] = reason;
j["audio_duration_sec"] = audio_duration;
j["audio_rms"] = audio_rms;
j["timestamp"] = seg.timestamp;
std::ofstream file(filename.str());
if (file.is_open()) {
file << j.dump(2);
file.close();
} }
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() { void SessionLogger::writeSessionJson() {
json session;
session["start_time"] = session_start_time_;
session["end_time"] = getCurrentTimestamp();
session["total_segments"] = segment_count_;
session["filtered_segments"] = filtered_count_;
session["total_audio_seconds"] = total_audio_sec_;
session["avg_whisper_latency_ms"] = segment_count_ > 0 ?
total_whisper_ms_ / segment_count_ : 0;
session["avg_claude_latency_ms"] = segment_count_ > 0 ?
total_claude_ms_ / segment_count_ : 0;
// Summary of all segments
json segments_summary = json::array();
for (const auto& seg : segments_) {
json s;
s["id"] = seg.id;
s["chinese"] = seg.chinese;
s["french"] = seg.french;
s["duration"] = seg.audio_duration_sec;
s["filtered"] = seg.was_filtered;
if (seg.was_filtered) {
s["filter_reason"] = seg.filter_reason;
}
segments_summary.push_back(s);
}
session["segments"] = segments_summary;
std::string filepath = session_path_ + "/session.json"; std::string filepath = session_path_ + "/session.json";
std::ofstream file(filepath); std::ofstream file(filepath, std::ios::out | std::ios::binary);
if (file.is_open()) { if (!file.is_open()) {
file << session.dump(2); std::cerr << "[SessionLogger] Failed to write session.json" << std::endl;
file.close(); return;
std::cout << "[Session] Wrote " << filepath << std::endl;
} }
// Also write plain text transcript // Write UTF-8 BOM
std::string transcript_path = session_path_ + "/transcript.txt"; file << "\xEF\xBB\xBF";
std::ofstream transcript(transcript_path);
if (transcript.is_open()) { // Manual JSON construction (to avoid extra dependencies)
transcript << "=== SecondVoice Session " << session_start_time_ << " ===\n\n"; file << "{\n";
for (const auto& seg : segments_) { file << " \"session_id\": \"" << metadata_.session_id << "\",\n";
if (!seg.was_filtered) { file << " \"start_time\": \"" << formatTimestamp(metadata_.start_time) << "\",\n";
transcript << "CN: " << seg.chinese << "\n"; file << " \"end_time\": \"" << formatTimestamp(metadata_.end_time) << "\",\n";
transcript << "FR: " << seg.french << "\n\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;
} }
} }
transcript.close(); 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 } // namespace secondvoice

View File

@ -4,25 +4,54 @@
#include <vector> #include <vector>
#include <chrono> #include <chrono>
#include <fstream> #include <fstream>
#include <mutex>
namespace secondvoice { namespace secondvoice {
struct SegmentLog { struct SegmentData {
int id; int id;
std::string chinese; std::string chinese;
std::string french; std::string french;
float audio_duration_sec;
float audio_rms; // Audio metadata
int64_t whisper_latency_ms; float duration_seconds;
int64_t claude_latency_ms; 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; bool was_filtered;
std::string filter_reason; std::string filter_reason;
std::string timestamp;
// VAD metrics (added for TASK8) // Audio file (optional)
int speech_duration_ms = 0; std::string audio_filename;
int silence_duration_ms = 0;
std::string flush_reason = ""; // 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 { class SessionLogger {
@ -30,39 +59,46 @@ public:
SessionLogger(); SessionLogger();
~SessionLogger(); ~SessionLogger();
// Start a new session (creates directory) // Start a new session - creates directory structure
void startSession(); bool startSession();
// End session (writes session.json summary) // End session - writes session.json
void endSession(); void endSession();
// Log a segment // Log a segment (called after transcription+translation)
void logSegment(const SegmentLog& segment); void logSegment(const SegmentData& segment);
// Log a filtered/skipped segment // Save audio data for a segment (returns filename)
void logFilteredSegment(const std::string& chinese, const std::string& reason, // Also calculates audio_hashes (one hash per second)
float audio_duration, float audio_rms); std::string saveSegmentAudio(int segment_id, const std::vector<float>& audio_data,
int sample_rate, int channels,
std::vector<std::string>& out_hashes);
// Get current session path // Update session metadata
std::string getSessionPath() const { return session_path_; } void setVadSettings(float rms_thresh, float peak_thresh);
void setModels(const std::string& whisper_model, const std::string& claude_model);
// Check if session is active // Getters
bool isActive() const { return is_active_; } 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: private:
std::string getCurrentTimestamp() const;
void writeSessionJson(); void writeSessionJson();
void writeSegmentJson(const SegmentData& segment);
std::string formatTimestamp(const std::chrono::system_clock::time_point& tp) const;
bool is_active_ = false;
std::string session_path_; std::string session_path_;
std::string session_start_time_; std::string segments_path_;
int segment_count_ = 0; SessionMetadata metadata_;
int filtered_count_ = 0; std::vector<SegmentData> segments_;
float total_audio_sec_ = 0.0f;
int total_whisper_ms_ = 0;
int total_claude_ms_ = 0;
std::vector<SegmentLog> segments_; int next_segment_id_ = 1;
bool session_active_ = false;
mutable std::mutex mutex_;
}; };
} // namespace secondvoice } // 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
═══════════════════════════════════════════════════════════════