Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.250.101"
VERSION = "0.250.102"
IS_DEVELOPMENT = is_development_env_enabled()

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Expand Down
35 changes: 35 additions & 0 deletions application/single_app/static/js/chat/chat-message-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@
return '';
}

function getMessagePlainText(messageDiv) {
const messageText = messageDiv.querySelector('.message-text');
if (!messageText) {
return '';
}
return String(messageText.innerText || messageText.textContent || '').trim();
}

/**
* Get the sender label from a message div.
*/
Expand Down Expand Up @@ -187,6 +195,33 @@
showToast('Message exported as Markdown.', 'success');
}

/**
* Export a single message as an MP3 using the active TTS voice and speed.
*/
export async function exportMessageAsAudio(messageDiv, messageId, role) {

Check warning on line 201 in application/single_app/static/js/chat/chat-message-export.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
if (!window.appSettings?.enable_text_to_speech) {
showToast('Text-to-speech is not enabled.', 'warning');
return;
}

const content = getMessagePlainText(messageDiv);
if (!content) {
showToast('No message content to export.', 'warning');
return;
}

try {
const { synthesizeSpeechBlob } = await import('./chat-tts.js');

Check warning on line 214 in application/single_app/static/js/chat/chat-message-export.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
const audioBlob = await synthesizeSpeechBlob(content);

Check warning on line 215 in application/single_app/static/js/chat/chat-message-export.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
const filename = `message_audio_${filenameTimestamp()}.mp3`;
downloadBlob(audioBlob, filename);

Check warning on line 217 in application/single_app/static/js/chat/chat-message-export.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
showToast('Message exported as audio.', 'success');
} catch (err) {
console.error('Error exporting message to audio:', err);
showToast(err.message || 'Failed to export message as audio.', 'danger');
}
}

/**
* Export a single message as a Word (.docx) file by calling the backend
* endpoint which uses python-docx to generate the document.
Expand Down
23 changes: 23 additions & 0 deletions application/single_app/static/js/chat/chat-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,14 @@ function isWorkspaceDocumentSearchEnabled() {
}

const INLINE_ASSISTANT_EXPORT_ACTIONS = Object.freeze({
audio: {
actionName: 'exportMessageAsAudio',
buttonClass: 'inline-export-audio-btn',
iconClass: 'bi bi-file-earmark-music',
label: 'Create Audio File',
pendingLabel: 'Creating Audio File...',
title: 'Create Audio File',
},
powerpoint: {
actionName: 'exportMessageAsPowerPoint',
buttonClass: 'inline-export-ppt-btn',
Expand Down Expand Up @@ -4769,6 +4777,10 @@ function renderReplyQuoteHtml(fullMessageObject = null) {

function attachMessageExportActionListeners(messageDiv, role) {
const actionMappings = [
{
selectors: ['.dropdown-export-audio-btn', '.inline-export-audio-btn'],
actionName: 'exportMessageAsAudio',
},
{
selectors: ['.dropdown-export-md-btn', '.inline-export-md-btn'],
actionName: 'exportMessageAsMarkdown',
Expand Down Expand Up @@ -4796,6 +4808,9 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
messageDiv.querySelectorAll(selector).forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault();
if (button.getAttribute('aria-busy') === 'true') {
return;
}
void triggerMessageExportAction(messageDiv, role, actionName, button);
});
});
Expand Down Expand Up @@ -4911,11 +4926,15 @@ export function appendMessage(
`;

const maskButtonHtml = buildMaskControlsHtml(messageId, maskState);
const audioExportMenuItemHtml = window.appSettings?.enable_text_to_speech
? '<li><a class="dropdown-item dropdown-export-audio-btn" href="#" data-default-label="Export to Audio" data-pending-label="Creating Audio File..." data-icon-class="bi bi-file-earmark-music" data-default-title="Export to Audio"><i class="bi bi-file-earmark-music me-2"></i>Export to Audio</a></li>'
: '';
const exportMenuItemsHtml = renderCompletedAssistantActions ? `
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item dropdown-export-md-btn" href="#" data-message-id="${messageId}"><i class="bi bi-markdown me-2"></i>Export to Markdown</a></li>
<li><a class="dropdown-item dropdown-export-word-btn" href="#" data-message-id="${messageId}"><i class="bi bi-file-earmark-word me-2"></i>Export to Word</a></li>
<li><a class="dropdown-item dropdown-export-ppt-btn" href="#" data-message-id="${messageId}"><i class="bi bi-file-earmark-slides me-2"></i>Export to PowerPoint</a></li>
${audioExportMenuItemHtml}
<li><a class="dropdown-item dropdown-copy-prompt-btn" href="#" data-message-id="${messageId}"><i class="bi bi-clipboard-plus me-2"></i>Use as Prompt</a></li>
<li><a class="dropdown-item dropdown-open-email-btn" href="#" data-message-id="${messageId}"><i class="bi bi-envelope me-2"></i>Open in Email</a></li>` : '';
const forkConversationMenuItemHtml = shouldRenderConversationForkAction(messageId, fullMessageObject)
Expand Down Expand Up @@ -5417,6 +5436,9 @@ export function appendMessage(
if (sender === "You") {
const metadataContainerId = `metadata-${messageId || Date.now()}`;
const maskState = getMaskStateFromMetadata(fullMessageObject?.metadata);
const audioExportMenuItemHtml = window.appSettings?.enable_text_to_speech
? '<li><a class="dropdown-item dropdown-export-audio-btn" href="#" data-default-label="Export to Audio" data-pending-label="Creating Audio File..." data-icon-class="bi bi-file-earmark-music" data-default-title="Export to Audio"><i class="bi bi-file-earmark-music me-2"></i>Export to Audio</a></li>'
: '';

messageFooterHtml = `
<div class="message-footer d-flex justify-content-between align-items-center mt-2">
Expand All @@ -5433,6 +5455,7 @@ export function appendMessage(
<li><a class="dropdown-item dropdown-export-md-btn" href="#" data-message-id="${messageId}"><i class="bi bi-markdown me-2"></i>Export to Markdown</a></li>
<li><a class="dropdown-item dropdown-export-word-btn" href="#" data-message-id="${messageId}"><i class="bi bi-file-earmark-word me-2"></i>Export to Word</a></li>
<li><a class="dropdown-item dropdown-export-ppt-btn" href="#" data-message-id="${messageId}"><i class="bi bi-file-earmark-slides me-2"></i>Export to PowerPoint</a></li>
${audioExportMenuItemHtml}
<li><a class="dropdown-item dropdown-copy-prompt-btn" href="#" data-message-id="${messageId}"><i class="bi bi-clipboard-plus me-2"></i>Use as Prompt</a></li>
<li><a class="dropdown-item dropdown-open-email-btn" href="#" data-message-id="${messageId}"><i class="bi bi-envelope me-2"></i>Open in Email</a></li>
</ul>
Expand Down
49 changes: 27 additions & 22 deletions application/single_app/static/js/chat/chat-tts.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,33 +237,38 @@
}

/**
* Synthesize a text chunk and return Audio element
* Synthesize text with the active voice and speed and return an MP3 Blob.

Check warning on line 240 in application/single_app/static/js/chat/chat-tts.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
*/
export async function synthesizeSpeechBlob(text) {

Check warning on line 242 in application/single_app/static/js/chat/chat-tts.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
const response = await fetch('/api/chat/tts', {

Check warning on line 243 in application/single_app/static/js/chat/chat-tts.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains external connection or remote asset marker. Recommendation%3A Review whether changed code can send prompts, files, credentials, cookies, settings, logs, or user data to a new sink.
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: text,
voice: ttsVoice,
speed: ttsSpeed
})
});

if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || 'Failed to generate speech');
}

return response.blob();

Check warning on line 260 in application/single_app/static/js/chat/chat-tts.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
}

/**
* Synthesize a text chunk and return an Audio element.
*/
async function synthesizeChunk(text, messageId) {
try {
const response = await fetch('/api/chat/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: text,
voice: ttsVoice,
speed: ttsSpeed
})
});

if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to generate speech');
}

// Get audio blob
const audioBlob = await response.blob();
const audioBlob = await synthesizeSpeechBlob(text);

Check warning on line 268 in application/single_app/static/js/chat/chat-tts.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
const audioUrl = URL.createObjectURL(audioBlob);

return new Audio(audioUrl);

} catch (error) {
console.error('Error synthesizing chunk:', error);
throw error;
Expand Down
67 changes: 67 additions & 0 deletions docs/explanation/features/MESSAGE_AUDIO_EXPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Message Audio Export

Version implemented: **0.250.102**

Fixed/Implemented in version: **0.250.102**

## Overview

SimpleChat users can export an individual user or assistant chat message as an MP3 audio file. The export reuses the chat text-to-speech service and the user's selected voice and playback speed.

Related issue: microsoft/simplechat#628

## Dependencies

- `application/single_app/config.py` version `0.250.102`
- Azure Speech Service configured in Admin Settings
- **Enable Text-to-Speech Chat Output** enabled
- A browser that supports Blob downloads

Check warning on line 18 in docs/explanation/features/MESSAGE_AUDIO_EXPORT.md

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.

## Technical Specifications

### Architecture

1. The message action reads visible plain text from the selected message.
2. The browser sends the text, active voice, and active speed to the existing authenticated `POST /api/chat/tts` endpoint.
3. Azure Speech synthesizes `Audio48Khz192KBitRateMonoMp3` output.
4. The browser downloads the returned `audio/mpeg` Blob with a timestamped `.mp3` filename.

SimpleChat does not save generated audio to Cosmos DB or Blob Storage. The audio bytes are transient between Azure Speech, the application response, and the user's browser download.

### Files

- `application/single_app/static/js/chat/chat-tts.js` provides shared MP3 Blob synthesis with the active TTS preferences.
- `application/single_app/static/js/chat/chat-message-export.js` converts visible message text into a downloadable MP3.
- `application/single_app/static/js/chat/chat-messages.js` adds the feature-gated message actions.
- `application/single_app/route_backend_tts.py` remains the authenticated synthesis endpoint.

### Security and access

- The action is rendered only when `enable_text_to_speech` is enabled.
- The backend endpoint remains protected by the backend TTS Blueprint user policy and route-level authentication decorators.
- The browser sends message text for synthesis; it does not send caller-selected conversation, workspace, or user identifiers.
- Service failures return client-safe errors through existing Bootstrap toast notifications.

## Usage Instructions

1. Configure Azure Speech Service in **Admin Settings**.
2. Enable **Text-to-Speech Chat Output**.
3. Open a chat containing a completed user or assistant message.
4. Open the message's **More actions** menu.
5. Select **Export to Audio**.
6. Wait for synthesis to complete and save the downloaded `message_audio_YYYYMMDD_HHMMSS.mp3` file.

The downloaded audio uses the voice and speed selected in the chat text-to-speech controls when the export begins. Speech-to-text input can remain disabled because message audio export is a text-to-speech feature.

## Testing and Validation

- `functional_tests/test_message_audio_export.py` validates the MP3 endpoint contract, active preference usage, feature gating, menu wiring, and version.
- `ui_tests/test_chat_message_audio_export.py` validates browser synthesis and downloads for user and assistant messages and verifies disabled deployments cannot invoke synthesis through the export function.
- Existing text-to-speech tests continue to cover playback, autoplay, voice selection, and speed formatting.

## Known Limitations

- MP3 is the only export format in this version.
- Azure Speech configuration, throttling, quotas, supported voices, and synthesis limits apply.
- Only visible message text is spoken. Markdown syntax, HTML markup, hidden metadata, and non-text generated artifacts are not included.
- Streaming assistant messages expose the action only after the message is complete.
9 changes: 9 additions & 0 deletions docs/explanation/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/).

### **(v0.250.102)**

#### New Features

* **Per-Message Audio Export**
* Users can export completed user and assistant chat messages as MP3 audio when text-to-speech is enabled.
* Downloads reuse the active Azure Speech voice and speed, include only visible message text, and remain transient without storing generated audio in SimpleChat.
* (Ref: microsoft/simplechat#628, `chat-tts.js`, `chat-message-export.js`, `chat-messages.js`, `MESSAGE_AUDIO_EXPORT.md`)

### **(v0.250.101)**

#### New Features
Expand Down
4 changes: 2 additions & 2 deletions functional_tests/test_chat_tts_autoplay_toggle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# test_chat_tts_autoplay_toggle.py
"""
Functional test for chat AI voice response autoplay toggle.
Version: 0.242.048
Version: 0.250.102
Implemented in: 0.242.048

This test ensures the chat AI voice response toggle enables text-to-speech
Expand Down Expand Up @@ -30,7 +30,7 @@ def test_chat_tts_autoplay_toggle_enables_tts_state():
config_content = read_text(CONFIG_FILE)
chat_tts_content = read_text(CHAT_TTS_JS)

assert 'VERSION = "0.242.048"' in config_content
assert 'VERSION = "0.250.102"' in config_content
assert "ttsEnabled = Boolean(settings.ttsEnabled || settings.ttsAutoplay);" in chat_tts_content
assert "const previousTTSEnabled = ttsEnabled;" in chat_tts_content
assert "if (ttsAutoplay) {\n ttsEnabled = true;\n }" in chat_tts_content
Expand Down
96 changes: 96 additions & 0 deletions functional_tests/test_message_audio_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# test_message_audio_export.py
#!/usr/bin/env python3
"""
Functional test for per-message MP3 audio export.
Version: 0.250.102
Implemented in: 0.250.102

This test ensures user and assistant messages expose a text-to-speech-gated
audio export that downloads MP3 bytes using the active TTS voice and speed.
"""

from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[1]
CONFIG_FILE = REPO_ROOT / "application" / "single_app" / "config.py"
EXPORT_FILE = REPO_ROOT / "application" / "single_app" / "static" / "js" / "chat" / "chat-message-export.js"
MESSAGES_FILE = REPO_ROOT / "application" / "single_app" / "static" / "js" / "chat" / "chat-messages.js"
TTS_FILE = REPO_ROOT / "application" / "single_app" / "static" / "js" / "chat" / "chat-tts.js"
TTS_ROUTE_FILE = REPO_ROOT / "application" / "single_app" / "route_backend_tts.py"


def read_text(path: Path) -> str:
"""Read a repository file as UTF-8 text."""
return path.read_text(encoding="utf-8")


def test_audio_export_uses_existing_mp3_tts_contract():
"""Verify audio export reuses the authenticated MP3 synthesis endpoint."""
tts_source = read_text(TTS_FILE)
route_source = read_text(TTS_ROUTE_FILE)

assert "export async function synthesizeSpeechBlob(text)" in tts_source
assert "fetch('/api/chat/tts'" in tts_source
assert "voice: ttsVoice" in tts_source
assert "speed: ttsSpeed" in tts_source
assert "return response.blob();" in tts_source
assert "Audio48Khz192KBitRateMonoMp3" in route_source
assert "mimetype='audio/mpeg'" in route_source
assert "as_attachment=False" in route_source


def test_audio_export_downloads_visible_message_text():
"""Verify the browser downloads visible message text as a timestamped MP3."""
export_source = read_text(EXPORT_FILE)

assert "function getMessagePlainText(messageDiv)" in export_source
assert "messageText.innerText || messageText.textContent" in export_source
assert "export async function exportMessageAsAudio(messageDiv, messageId, role)" in export_source
assert "window.appSettings?.enable_text_to_speech" in export_source
assert "const { synthesizeSpeechBlob } = await import('./chat-tts.js');" in export_source
assert "const audioBlob = await synthesizeSpeechBlob(content);" in export_source
assert "message_audio_${filenameTimestamp()}.mp3" in export_source
assert "downloadBlob(audioBlob, filename);" in export_source


def test_audio_export_menu_is_gated_for_user_and_assistant_messages():
"""Verify both message roles expose the action only under the TTS flag."""
messages_source = read_text(MESSAGES_FILE)

assert "actionName: 'exportMessageAsAudio'" in messages_source
assert "selectors: ['.dropdown-export-audio-btn', '.inline-export-audio-btn']" in messages_source
assert messages_source.count("const audioExportMenuItemHtml = window.appSettings?.enable_text_to_speech") == 2
assert messages_source.count("dropdown-export-audio-btn") >= 3
assert messages_source.count("${audioExportMenuItemHtml}") == 2
assert 'data-default-label="Export to Audio"' in messages_source
assert 'data-pending-label="Creating Audio File..."' in messages_source


def test_audio_export_version_is_current():
"""Verify the feature version is recorded in application configuration."""
assert 'VERSION = "0.250.102"' in read_text(CONFIG_FILE)


if __name__ == "__main__":
tests = [
test_audio_export_uses_existing_mp3_tts_contract,
test_audio_export_downloads_visible_message_text,
test_audio_export_menu_is_gated_for_user_and_assistant_messages,
test_audio_export_version_is_current,
]
results = []

for test in tests:
print(f"\nTesting {test.__name__}...")
try:
test()
print("Test passed")
results.append(True)
except Exception as ex:
print(f"Test failed: {ex}")
results.append(False)

passed = sum(results)
print(f"\nResults: {passed}/{len(results)} tests passed")
raise SystemExit(0 if all(results) else 1)
2 changes: 1 addition & 1 deletion functional_tests/test_tts_speech_speed_prosody_rate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#!/usr/bin/env python3
"""
Functional test for TTS speech speed prosody rate formatting.
Version: 0.241.102
Version: 0.250.102
Implemented in: 0.241.102

This test ensures that chat text-to-speech speed multipliers are translated
Expand Down
Loading
Loading