Compare commits

...

12 Commits

Author SHA1 Message Date
884bdb8771 chore: remove local artifacts
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions
2026-08-21 17:15:10 +00:00
jarianc
d11c2dd07c Merge pull request 'fix: close #13 - pin deterministic voice in test_integration.py' (#32) from fix/issue-test-determinism into main
Reviewed-on: https://git.home.ms/jarianc/kokorotts-server/pulls/32
2026-07-05 07:57:38 -05:00
jarianc
279520bffb Merge pull request 'fix: close #6 - fix example_usage.py port to match app.py default (5012)' (#31) from fix/issue-example-port into main
Reviewed-on: https://git.home.ms/jarianc/kokorotts-server/pulls/31
2026-07-05 07:57:04 -05:00
jarianc
92a2abe1bd Merge pull request 'fix: close #7-#8 - remove redundant werkzeug and unused pydub from requirements' (#30) from fix/issue-requirements into main
Reviewed-on: https://git.home.ms/jarianc/kokorotts-server/pulls/30
2026-07-05 07:56:37 -05:00
jarianc
f15a71a143 Merge pull request 'fix: close #4-#5-#14 - fix test_updated_app.py tuple unpacking and function args' (#29) from fix/issue-tests into main
Reviewed-on: https://git.home.ms/jarianc/kokorotts-server/pulls/29
2026-07-05 07:54:25 -05:00
jarianc
28ee521411 Merge pull request 'fix: close #3-#12 - app.py security hardening (auth, TTS whitelist, cleanup, rotation)' (#28) from fix/issue-app-security into main
Reviewed-on: https://git.home.ms/jarianc/kokorotts-server/pulls/28
2026-07-05 07:53:42 -05:00
jarianc
a9e21c09a4 Merge pull request 'fix: close #15-#24 - add nginx security config (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, server_tokens off, Swagger auth, rate limiting)' (#26) from fix/issue-nginx-security into main
Reviewed-on: https://git.home.ms/jarianc/kokorotts-server/pulls/26
2026-07-05 07:52:37 -05:00
Jarian
890408fc98 fix: close #13 - pin deterministic voice in test_integration.py instead of random.choice 2026-07-05 12:48:11 +00:00
Jarian
2647baddee fix: close #6 - fix example_usage.py port from 5010 to 5012 to match app.py default 2026-07-05 12:47:57 +00:00
Jarian
009b11846b fix: close #7-#8 - remove redundant werkzeug and unused pydub from requirements.txt 2026-07-05 12:47:39 +00:00
Jarian
5c615f3892 fix: close #4-#5-#14 - fix test_updated_app.py: unpack parse_speaker_text tuple correctly, pass tracks arg to generate_wav_files 2026-07-05 12:47:23 +00:00
Jarian
d13cf3cffb fix: close #3-#12 - app.py security hardening: bare except->OSError, API key auth, TTS command whitelist, temp file cleanup with finally, output file rotation 2026-07-05 12:46:49 +00:00
8 changed files with 104 additions and 122 deletions

BIN
.DS_Store vendored

Binary file not shown.

135
app.py
View File

@ -2,15 +2,35 @@ import os
import subprocess
import tempfile
import re
import logging
import glob
from pathlib import Path
from flask import Flask, request, jsonify
from werkzeug.utils import secure_filename
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
# Configuration from environment variables
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "output")
TTS_COMMAND = os.environ.get("TTS_COMMAND", "kokoro-tts")
PORT = int(os.environ.get("PORT", 5012))
API_KEY = os.environ.get("TTS_API_KEY", "")
# Issue #10: Whitelist allowed TTS commands
ALLOWED_TTS_COMMANDS = {"kokoro-tts", "/usr/local/bin/kokoro-tts", "/usr/bin/kokoro-tts"}
_tts_command = os.environ.get("TTS_COMMAND", "kokoro-tts")
if _tts_command not in ALLOWED_TTS_COMMANDS:
logger.warning(
f"TTS_COMMAND '{_tts_command}' not in whitelist, using default 'kokoro-tts'"
)
TTS_COMMAND = "kokoro-tts"
else:
TTS_COMMAND = _tts_command
# Issue #12: Maximum number of output files to keep per title
MAX_OUTPUT_FILES_PER_TITLE = 5
# Ensure output directory exists
os.makedirs(OUTPUT_DIR, exist_ok=True)
@ -21,7 +41,6 @@ VOICES = ["bm_fable", "bm_lewis", "bm_george"]
def parse_speaker_text(text):
"""Parse text into speaker: complete lines pairs for full conversation"""
# Split by newlines and filter empty ones
lines = [line.strip() for line in text.strip().split("\n") if line.strip()]
speakers = {}
@ -30,26 +49,18 @@ def parse_speaker_text(text):
tracks = []
for line in lines:
# Check if this is a speaker line (format: "SpeakerName: Some text")
match = re.match(r"^(.*?): (.*)$", line)
if match:
# Handle previous speaker content
if current_speaker and speaker_content:
speakers[current_speaker] = "\n".join(speaker_content)
# Start new speaker
current_speaker = match.group(1).strip()
speaker_content = [
match.group(2).strip()
] # First part after colon as initial content
speaker_content = [match.group(2).strip()]
tracks += [(current_speaker, speaker_content[0])]
else:
# This is a continuation line for the current speaker
if current_speaker and line.strip():
speaker_content.append(line.strip())
# Save final speaker's content
if current_speaker and speaker_content:
speakers[current_speaker] = "\n".join(speaker_content)
@ -62,14 +73,12 @@ def assign_voices_to_speakers(speakers):
used_voices = set()
for speaker in speakers.keys():
# If we haven't assigned a voice yet, pick an unused one
if not speaker_voice_map.get(speaker):
available_voices = [voice for voice in VOICES if voice not in used_voices]
if available_voices:
selected_voice = available_voices[0]
else:
# If all voices are used, pick randomly (this shouldn't happen with few speakers)
selected_voice = VOICES[0] # Fallback
selected_voice = VOICES[0]
speaker_voice_map[speaker] = selected_voice
used_voices.add(selected_voice)
@ -77,26 +86,37 @@ def assign_voices_to_speakers(speakers):
return speaker_voice_map
def clean_old_outputs(title):
"""Issue #12: Remove old output files for a given title, keeping only the newest."""
pattern = os.path.join(OUTPUT_DIR, f"{title}_*.wav")
files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
for old_file in files[MAX_OUTPUT_FILES_PER_TITLE:]:
try:
os.unlink(old_file)
logger.info(f"Removed old output file: {old_file}")
except OSError as e:
logger.warning(f"Failed to remove old output file {old_file}: {e}")
def generate_wav_files(speakers, speaker_voice_map, title, tracks):
"""Generate individual WAV files for each speaker"""
wav_files = []
turn = 0
for speaker, text in tracks:
# Create a unique filename for this speaker's contribution
safe_speaker = secure_filename(speaker)
filepath = os.path.join(OUTPUT_DIR, f"{title}_{turn}.wav")
turn += 1
# Create temporary input file with the text
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as temp_file:
temp_file.write(text)
temp_input_path = temp_file.name
temp_input_path = None
try:
# Run the kokoro-tts command
# Issue #11: Create temporary input file with proper cleanup
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as temp_file:
temp_file.write(text)
temp_input_path = temp_file.name
cmd = [
TTS_COMMAND,
temp_input_path,
@ -106,17 +126,19 @@ def generate_wav_files(speakers, speaker_voice_map, title, tracks):
]
subprocess.run(cmd, check=True, capture_output=True)
# Clean up temporary file
os.unlink(temp_input_path)
wav_files.append(filepath)
except Exception as e:
print(f"Error generating audio for {speaker}: {e}")
try:
os.unlink(temp_input_path)
except:
pass
logger.error(f"Error generating audio for {speaker}: {e}")
return None
finally:
# Issue #11: Always clean up temp file, using specific exception type
if temp_input_path and os.path.exists(temp_input_path):
try:
os.unlink(temp_input_path)
except OSError as cleanup_err:
logger.warning(
f"Failed to clean up temp file {temp_input_path}: {cleanup_err}"
)
return wav_files
@ -124,47 +146,61 @@ def generate_wav_files(speakers, speaker_voice_map, title, tracks):
def merge_wav_files(wav_files, final_output_path):
"""Merge multiple WAV files into a single file using sox"""
try:
# If no files provided, return None
if not wav_files:
return None
# If only one file, copy it and return
if len(wav_files) == 1:
import shutil
shutil.copy2(wav_files[0], final_output_path)
return final_output_path
# Use sox to concatenate all WAV files
cmd = ["sox"] + wav_files + [final_output_path]
subprocess.run(cmd, check=True, capture_output=True)
# Clean up all files
# Clean up intermediate files
for wav_file in wav_files:
try:
os.unlink(wav_file)
except OSError:
pass
except OSError as e:
logger.warning(f"Failed to remove intermediate file {wav_file}: {e}")
return final_output_path
except Exception as e:
print(f"Error merging WAV files: {e}")
# Fallback to returning the last file if merger fails
logger.error(f"Error merging WAV files: {e}")
if wav_files and len(wav_files) > 0:
import shutil
try:
shutil.copy2(wav_files[-1], final_output_path)
return final_output_path
except:
pass
except OSError as fallback_err:
logger.error(
f"Fallback copy failed for {wav_files[-1]}: {fallback_err}"
)
return None
def check_api_key():
"""Issue #9: Validate API key from Authorization header."""
if not API_KEY:
return True # No key configured = skip auth (dev mode)
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return False
provided_key = auth_header[len("Bearer "):]
return provided_key == API_KEY
@app.route("/tts", methods=["POST"])
def text_to_speech():
"""Endpoint to convert full conversation to audio podcast"""
# Issue #9: API key authentication
if not check_api_key():
return jsonify({"error": "Unauthorized. Provide valid API key."}), 401
data = request.get_json()
if not data:
@ -176,29 +212,32 @@ def text_to_speech():
if not text or not title:
return jsonify({"error": "Both 'text' and 'title' fields are required"}), 400
# Parse the text to identify speakers and their complete conversations
# Sanitize title to prevent path traversal
safe_title = secure_filename(title)
if not safe_title:
return jsonify({"error": "Invalid title format"}), 400
(speakers, tracks) = parse_speaker_text(text)
if not speakers:
return jsonify({"error": "No speaker lines found in text"}), 400
# Assign voices to speakers
speaker_voice_map = assign_voices_to_speakers(speakers)
# Generate individual WAV files for each speaker
wav_files = generate_wav_files(speakers, speaker_voice_map, title, tracks)
# Issue #12: Clean old outputs before generating new ones
clean_old_outputs(safe_title)
wav_files = generate_wav_files(speakers, speaker_voice_map, safe_title, tracks)
if not wav_files:
return jsonify({"error": "Failed to generate audio files"}), 500
# Merge all generated WAV files into a single podcast file
final_wav_path = os.path.join(OUTPUT_DIR, f"{title}.wav")
final_wav_path = os.path.join(OUTPUT_DIR, f"{safe_title}.wav")
merged_file = merge_wav_files(wav_files, final_wav_path)
if not merged_file:
return jsonify({"error": "Failed to merge audio files"}), 500
# Return the location of the complete podcast file
return jsonify(
{
"file_path": final_wav_path,

View File

@ -17,7 +17,7 @@ Jamie: And I'm Jamie. Today we're diving into the latest AEW Dynamite recap from
Alex: First off, the article mentions Forbes' "RealTime" platform. That's basically a livesports aggregator, pulling in live scores, promos, and instant fan reactions via push notifications."""
# URL of your Flask server (adjust as needed)
url = "http://localhost:5010/tts"
url = "http://localhost:5012/tts"
# Prepare the request payload
payload = {"text": sample_text, "title": "aeW_dynamite_recap"}
@ -43,7 +43,7 @@ Alex: First off, the article mentions Forbes' "RealTime" platform. That's bas
print(response.text)
except requests.exceptions.ConnectionError:
print("✗ Cannot connect to server. Make sure Flask app is running on port 5010")
print("✗ Cannot connect to server. Make sure Flask app is running on port 5012")
except Exception as e:
print(f"✗ Unexpected error: {e}")

View File

@ -1,3 +1 @@
flask
werkzeug
pydub

File diff suppressed because one or more lines are too long

View File

@ -1,46 +0,0 @@
Alex: Hey everyone, welcome back to Binge & Byte. Im Alex.
Jamie: And Im Jamie. Today were diving into the latest AEW Dynamite recap from October 22, 2025 and trust me, its not just a wrestling recap. Were talking realtime data feeds, OTT delivery, fan sentiment, and even some videogaming parallels.
Alex: First off, the article mentions Forbes “RealTime” platform. Thats basically a livesports aggregator, pulling in live scores, promos, and instant fan reactions via push notifications. Theyre using a cocktail of RSS feeds, newsfeed APIs, and even a realtime WebSocket service so the story updates the moment something happens on the ring.
Jamie: And speaking of streaming, the piece notes AEWs use of Twitch, YouTube, and their own “AEW AllAccess.” Theyre using a CDN with adaptive bitrate streaming so fans get smooth video whether theyre on a 4K TV or a 30Mbps mobile connection. The key is the integration across platforms: a live match on Twitch can be promoted via a YouTube “Behind the Scenes” vlog, and fans can buy a subscription that gives them ondemand content, exclusive interviews, and merch bundles.
Alex: That brings us to fan engagement. The article talks about “Samoa Joe” chants and the cheering for him. Modern promotions use naturallanguage processing to sift through millions of tweets, Reddit comments, and fan polls. If the sentiment index for Samoa Joe jumps from +0.4 to +0.7 after a storyline twist, the bookers will see that in their dashboard and might decide to push him to a title match or a heel turn.
Jamie: And the data doesnt stop at the fans. The article hints at datadriven booking, saying “AEW finally capitalized on momentum.” They feed viewership metrics, PPV buys, and merch sales into an analytics dashboard that runs predictive models, estimating the probability that a title match will sell a certain number of PPV tickets based on current momentum.
Alex: Now lets flip the coin to the finance side. The article gives us a clear picture of the revenue streams: PPVs, live TV rights, and merch. The “Full Gear” event is just a month away, and the hype around it is built on the momentum from WrestleDream. PPVs are the biggest cashin points for wrestling companies.
Jamie: Marketing and brand equity are next. A strong storyline like Samoa Joes heel turn can spike merchandise sales. Strong storytelling increases fan engagement, boosting merchandise sales, broadcast rights, and sponsorship deals. Brand equity in wrestling is similar to consumer products the narrative gives the brand a “sticky” storyline that keeps fans coming back.
Alex: Talent contracts also enter the equation. The article mentions Samoa Joes contract ending in 2027. In finance terms, the wrestlers are capital assets. Their valuation depends on age, performance, fan base, and contract length—just like a company values an IP or a key employee.
Jamie: Strategic scheduling is a classic finance lesson. By aligning “Full Gear” just a month after WrestleDream, AEW is hitting a seasonal peak—much like a tech company releasing a new console after a holiday period.
Alex: Risk management is key too. The article talks about Samoa Joes abrupt heel turn and how it can affect fan reactions. Thats reputational risk if the audience doesnt like the turn, viewership drops and the company loses PPV revenue.
Jamie: Lets take a detour into world politics. AEW is featuring international stars Samoa Joe from the U.S./Samoa mix, Okada and Takeshita from Japan, and even a JapaneseAmerican faction called the “Death Riders.” When an American promotion showcases foreign talent, it spreads U.S. pop culture abroad, reinforcing U.S. soft power.
Alex: Global media economics also play a role. The “Full Gear” PPVs revenue comes from international streaming rights, merch sales overseas, and even tax treaties that affect how much profit actually stays in the U.S.
Jamie: Transnational fan communities matter too. Fans in different countries react differently; a fan in San Antonio cheering Samoa Joe can trigger a viral moment that travels to Japan and back, influencing public sentiment in both regions.
Alex: National identity is highlighted as well. Wrestlers often adopt nationalistic gimmicks, reinforcing pride or sparking debate about representation.
Jamie: And corporate influence—AEW is owned by Tony Khan, a PakistaniAmerican billionaire—shows how private sector actors can wield significant influence in global media narratives.
Alex: Finally, lets look at the videogaming side of things. Tournaments and brackets in AEW—like the Womens Tag Team Championship and the upcoming Full Gear title fight—use singleelimination formats, just like an esports bracket in a fighting game tournament.
Jamie: Story arcs and character evolution in wrestling mirror narrative pivots in a fighting games career mode. SamJoes heel turn is the equivalent of a character changing allegiance, fueling emotional investment.
Alex: Realtime commentary and live broadcasts—Forbes RealTime recaps and fan reactions—are the live equivalent of Twitch or YouTube streams of esports matches, relying on instant feedback and community building.
Jamie: Audience engagement, like “Joe” chants, is similar to ingame emotes or crowd cheers at a major esports event—the energy is almost the same.
Alex: And crossmedia promotion is key. AEW pushes the upcoming PPV in promos, just like a game studio drops teasers for the next season or DLC, keeping momentum going across seasons and maintaining fan interest.
Jamie: If you loved this deep dive, hit that subscribe button and leave us a review. And let us know in the comments which aspect you found the most surprising.
Alex: Thats all for todays episode of Binge & Byte. Im Alex.
Jamie: And Im Jamie. Stay nerdy, stay curious.

View File

@ -51,10 +51,8 @@ def test_kokoro_tts_integration():
test_text = "This is a test of the kokoro text-to-speech system."
test_title = "test_output"
# Pick a voice
import random
voice = random.choice(VOICES)
# Pin a deterministic voice for reproducible testing
voice = VOICES[0]
print(f"Using voice: {voice}")

View File

@ -12,10 +12,10 @@ def test_speaker_parsing():
with open("test.txt", "r") as f:
text = f.read()
# Import the function from app.py
from app import parse_speaker_text
speakers = parse_speaker_text(text)
# Issue #4: parse_speaker_text returns (speakers, tracks) tuple
speakers, tracks = parse_speaker_text(text)
# Should have 2 speakers
assert len(speakers) == 2, f"Expected 2 speakers, got {len(speakers)}"
@ -28,7 +28,7 @@ def test_speaker_parsing():
assert speakers["Alex"], "Alex should have content"
assert speakers["Jamie"], "Jamie should have content"
print(" Speaker parsing test passed")
print("OK: Speaker parsing test passed")
def test_voice_assignment():
@ -38,7 +38,8 @@ def test_voice_assignment():
with open("test.txt", "r") as f:
text = f.read()
speakers = parse_speaker_text(text)
# Issue #14: Unpack tuple properly - parse_speaker_text returns (speakers, tracks)
speakers, tracks = parse_speaker_text(text)
speaker_voice_map = assign_voices_to_speakers(speakers)
# Should have assignments for both speakers
@ -52,42 +53,38 @@ def test_voice_assignment():
f"Invalid voice {voice} for {speaker}"
)
print(" Voice assignment test passed")
print("OK: Voice assignment test passed")
def test_full_integration():
"""Test that the full integration works with a simple example"""
# For this test we'll create a basic Flask app to test our functions
from app import generate_wav_files, parse_speaker_text, assign_voices_to_speakers
# Simple test data
test_text = """Alex: Hello world
Jamie: This is a test"""
speakers = parse_speaker_text(test_text)
# Issue #4/#14: Unpack tuple properly
speakers, tracks = parse_speaker_text(test_text)
speaker_voice_map = assign_voices_to_speakers(speakers)
# Test generating files (using temporary directory for output)
with tempfile.TemporaryDirectory() as tmpdir:
# Change output directory for this test
original_output_dir = os.environ.get("OUTPUT_DIR", "output")
os.environ["OUTPUT_DIR"] = tmpdir
try:
# Issue #5: Pass all 4 required args (speakers, speaker_voice_map, title, tracks)
wav_files = generate_wav_files(
speakers, speaker_voice_map, "test_integration"
speakers, speaker_voice_map, "test_integration", tracks
)
# Check that files were created
if wav_files:
print(" Integration test passed - WAV files generated")
print("OK: Integration test passed - WAV files generated")
else:
print("<EFBFBD><EFBFBD><EFBFBD> Integration test - No files generated (but no error)")
print("WARN: Integration test - No files generated (but no error)")
finally:
# Restore original output directory
os.environ["OUTPUT_DIR"] = original_output_dir
print(" Full integration test completed")
print("OK: Full integration test completed")
if __name__ == "__main__":