fixed double voices
parent
4f4f90d7ad
commit
1764633554
|
@ -1,272 +1,273 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
|
||||||
/>
|
|
||||||
<!-- Theme color for Chrome, Firefox OS and Opera -->
|
|
||||||
<meta name="theme-color" content="#fd9644" />
|
|
||||||
<!-- Theme color for Windows Phone -->
|
|
||||||
<meta name="msapplication-navbutton-color" content="#fd9644" />
|
|
||||||
<!-- Theme color for iOS Safari -->
|
|
||||||
<meta
|
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
|
||||||
content="black-translucent"
|
|
||||||
/>
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
|
|
||||||
<title>Voice Call with a voice bot</title>
|
<head>
|
||||||
<style>
|
<meta charset="UTF-8" />
|
||||||
body {
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
display: flex;
|
<!-- Theme color for Chrome, Firefox OS and Opera -->
|
||||||
justify-content: center;
|
<meta name="theme-color" content="#fd9644" />
|
||||||
align-items: center;
|
<!-- Theme color for Windows Phone -->
|
||||||
height: 100vh;
|
<meta name="msapplication-navbutton-color" content="#fd9644" />
|
||||||
margin: 0;
|
<!-- Theme color for iOS Safari -->
|
||||||
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
|
||||||
|
<title>Voice Call with a voice bot</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-button {
|
||||||
|
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 36px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
will-change: box-shadow;
|
||||||
|
transition: box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #fd9644;
|
||||||
|
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14),
|
||||||
|
0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-button:hover {
|
||||||
|
box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14),
|
||||||
|
0 1px 10px 0 rgba(0, 0, 0, 0.12), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-button:active {
|
||||||
|
box-shadow: 0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||||
|
0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="center-container">
|
||||||
|
<h1>Voice Call with a Voice Bot</h1>
|
||||||
|
<button id="startCallButton" class="material-button">Start Call</button>
|
||||||
|
<p id="status">Status: Idle</p>
|
||||||
|
<h3>Input:</h3>
|
||||||
|
<p id="text-input">---</p>
|
||||||
|
<h3>Output:</h3>
|
||||||
|
<p id="text-output">---</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script>
|
||||||
|
const startCallButton = document.getElementById("startCallButton");
|
||||||
|
const status = document.getElementById("status");
|
||||||
|
const debugTextInput = document.getElementById("text-input");
|
||||||
|
const debugTextOutput = document.getElementById("text-output");
|
||||||
|
|
||||||
|
const socket = io(
|
||||||
|
`${window.location.protocol}//${window.location.hostname}`
|
||||||
|
); // Connect to your server
|
||||||
|
let localStream;
|
||||||
|
let audioContext;
|
||||||
|
let processor;
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
let audioQueue = [];
|
||||||
|
|
||||||
|
startCallButton.addEventListener("click", async () => {
|
||||||
|
if (started) {
|
||||||
|
socket.emit("bot-end");
|
||||||
|
processor.disconnect(audioContext.destination);
|
||||||
|
|
||||||
|
localStream.getTracks().forEach((track) => track.stop());
|
||||||
|
localStream = null;
|
||||||
|
|
||||||
|
audioContext.close();
|
||||||
|
audioContext = null;
|
||||||
|
|
||||||
|
processor = null;
|
||||||
|
|
||||||
|
startCallButton.textContent = "Start Call";
|
||||||
|
status.textContent = "Status: Call ended";
|
||||||
|
started = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-container {
|
started = true;
|
||||||
text-align: center;
|
|
||||||
|
startCallButton.textContent = "End Call";
|
||||||
|
|
||||||
|
status.textContent = "Status: Starting call...";
|
||||||
|
console.log("Starting call...");
|
||||||
|
|
||||||
|
// Get local audio stream
|
||||||
|
localStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: true,
|
||||||
|
});
|
||||||
|
console.log("Local audio stream obtained:", localStream);
|
||||||
|
|
||||||
|
socket.emit("bot-start");
|
||||||
|
|
||||||
|
// Create AudioContext with sample rate of 24000 Hz
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||||
|
sampleRate: 24000,
|
||||||
|
});
|
||||||
|
const source = audioContext.createMediaStreamSource(localStream);
|
||||||
|
processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
|
||||||
|
// Process audio data
|
||||||
|
processor.onaudioprocess = (event) => {
|
||||||
|
const inputData = event.inputBuffer.getChannelData(0);
|
||||||
|
|
||||||
|
socket.emit("bot-voice-data", inputData); // Send as ArrayBuffer
|
||||||
|
|
||||||
|
// echo audio locally
|
||||||
|
/*const outputData = event.outputBuffer.getChannelData(0);
|
||||||
|
for (let sample = 0; sample < inputData.length; sample++) {
|
||||||
|
outputData[sample] = inputData[sample];
|
||||||
|
}*/
|
||||||
|
|
||||||
|
let outputData = event.outputBuffer.getChannelData(0);
|
||||||
|
|
||||||
|
if (audioQueue.length > 0) {
|
||||||
|
const chunk = audioQueue.splice(0, outputData.length);
|
||||||
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
|
outputData[i] = chunk[i];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < outputData.length; i++) {
|
||||||
|
outputData[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioContext.destination);
|
||||||
|
|
||||||
|
status.textContent = "Status: Call started";
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("openai-response", (data) => {
|
||||||
|
console.log("OpenAI response received:", data);
|
||||||
|
try {
|
||||||
|
response.textContent = "Response: " + data[0].transcript;
|
||||||
|
} catch (error) { }
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("debug-text-input", (data) => {
|
||||||
|
debugTextInput.textContent = data;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("debug-text-output", (data) => {
|
||||||
|
debugTextOutput.textContent = data;
|
||||||
|
});
|
||||||
|
|
||||||
|
let dataSum = 0;
|
||||||
|
let lastByte = undefined;
|
||||||
|
let currentAudioID = "";
|
||||||
|
|
||||||
|
socket.on("openai-audio-start", (randomUUID) => {
|
||||||
|
console.log("OpenAI audio start:", randomUUID);
|
||||||
|
currentAudioID = randomUUID;
|
||||||
|
dataSum = 0;
|
||||||
|
stopAudio();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("openai-audio", async (data, randomUUID) => {
|
||||||
|
console.log("OpenAI audio received:", data, randomUUID);
|
||||||
|
if (currentAudioID !== randomUUID) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-button {
|
// Log the received data
|
||||||
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
|
console.log("Received data type:", data.constructor.name);
|
||||||
|
console.log("Received data:", data);
|
||||||
|
|
||||||
display: inline-block;
|
// Ensure data is an ArrayBuffer
|
||||||
padding: 0 16px;
|
if (!(data instanceof ArrayBuffer)) {
|
||||||
height: 36px;
|
console.error("Received data is not an ArrayBuffer");
|
||||||
font-size: 14px;
|
return;
|
||||||
font-weight: 500;
|
|
||||||
line-height: 36px;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
overflow: hidden;
|
|
||||||
will-change: box-shadow;
|
|
||||||
transition: box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #fff;
|
|
||||||
background-color: #fd9644;
|
|
||||||
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14),
|
|
||||||
0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-button:hover {
|
dataSum += data.byteLength;
|
||||||
box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14),
|
console.log("Received data sum:", dataSum);
|
||||||
0 1px 10px 0 rgba(0, 0, 0, 0.12), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-button:active {
|
try {
|
||||||
box-shadow: 0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
// Check if there was an odd byte from the previous chunk
|
||||||
0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2);
|
if (lastByte !== undefined) {
|
||||||
}
|
// Combine the last byte with the current data
|
||||||
</style>
|
const lastData = new Uint8Array(
|
||||||
</head>
|
lastByte.byteLength + data.byteLength
|
||||||
|
);
|
||||||
<body>
|
lastData.set(new Uint8Array(lastByte), 0);
|
||||||
<div class="center-container">
|
lastData.set(new Uint8Array(data), lastByte.byteLength);
|
||||||
<h1>Voice Call with a Voice Bot</h1>
|
data = lastData;
|
||||||
<button id="startCallButton" class="material-button">Start Call</button>
|
lastByte = undefined; // Reset lastByte since it has been processed
|
||||||
<p id="status">Status: Idle</p>
|
|
||||||
<h3>Input:</h3>
|
|
||||||
<p id="text-input">---</p>
|
|
||||||
<h3>Output:</h3>
|
|
||||||
<p id="text-output">---</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
|
||||||
<script>
|
|
||||||
const startCallButton = document.getElementById("startCallButton");
|
|
||||||
const status = document.getElementById("status");
|
|
||||||
const debugTextInput = document.getElementById("text-input");
|
|
||||||
const debugTextOutput = document.getElementById("text-output");
|
|
||||||
|
|
||||||
const socket = io(
|
|
||||||
`${window.location.protocol}//${window.location.hostname}`
|
|
||||||
); // Connect to your server
|
|
||||||
let localStream;
|
|
||||||
let audioContext;
|
|
||||||
let processor;
|
|
||||||
let started = false;
|
|
||||||
|
|
||||||
let audioQueue = [];
|
|
||||||
|
|
||||||
startCallButton.addEventListener("click", async () => {
|
|
||||||
if (started) {
|
|
||||||
socket.emit("bot-end");
|
|
||||||
processor.disconnect(audioContext.destination);
|
|
||||||
|
|
||||||
localStream.getTracks().forEach((track) => track.stop());
|
|
||||||
localStream = null;
|
|
||||||
|
|
||||||
audioContext.close();
|
|
||||||
audioContext = null;
|
|
||||||
|
|
||||||
processor = null;
|
|
||||||
|
|
||||||
startCallButton.textContent = "Start Call";
|
|
||||||
status.textContent = "Status: Call ended";
|
|
||||||
started = false;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
started = true;
|
// Check if the combined data has an odd number of bytes
|
||||||
|
if (data.byteLength % 2 !== 0) {
|
||||||
startCallButton.textContent = "End Call";
|
// Save the last byte for the next chunk
|
||||||
|
lastByte = data.slice(data.byteLength - 1);
|
||||||
status.textContent = "Status: Starting call...";
|
// Remove the last byte from the current data
|
||||||
console.log("Starting call...");
|
data = data.slice(0, data.byteLength - 1);
|
||||||
|
|
||||||
// Get local audio stream
|
|
||||||
localStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true,
|
|
||||||
});
|
|
||||||
console.log("Local audio stream obtained:", localStream);
|
|
||||||
|
|
||||||
socket.emit("bot-start");
|
|
||||||
|
|
||||||
// Create AudioContext with sample rate of 24000 Hz
|
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
||||||
sampleRate: 24000,
|
|
||||||
});
|
|
||||||
const source = audioContext.createMediaStreamSource(localStream);
|
|
||||||
processor = audioContext.createScriptProcessor(4096, 1, 1);
|
|
||||||
|
|
||||||
// Process audio data
|
|
||||||
processor.onaudioprocess = (event) => {
|
|
||||||
const inputData = event.inputBuffer.getChannelData(0);
|
|
||||||
|
|
||||||
socket.emit("bot-voice-data", inputData); // Send as ArrayBuffer
|
|
||||||
|
|
||||||
// echo audio locally
|
|
||||||
/*const outputData = event.outputBuffer.getChannelData(0);
|
|
||||||
for (let sample = 0; sample < inputData.length; sample++) {
|
|
||||||
outputData[sample] = inputData[sample];
|
|
||||||
}*/
|
|
||||||
|
|
||||||
let outputData = event.outputBuffer.getChannelData(0);
|
|
||||||
|
|
||||||
if (audioQueue.length > 0) {
|
|
||||||
const chunk = audioQueue.splice(0, outputData.length);
|
|
||||||
for (let i = 0; i < chunk.length; i++) {
|
|
||||||
outputData[i] = chunk[i];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < outputData.length; i++) {
|
|
||||||
outputData[i] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
source.connect(processor);
|
|
||||||
processor.connect(audioContext.destination);
|
|
||||||
|
|
||||||
status.textContent = "Status: Call started";
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("openai-response", (data) => {
|
|
||||||
console.log("OpenAI response received:", data);
|
|
||||||
try {
|
|
||||||
response.textContent = "Response: " + data[0].transcript;
|
|
||||||
} catch (error) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("debug-text-input", (data) => {
|
|
||||||
debugTextInput.textContent = data;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("debug-text-output", (data) => {
|
|
||||||
debugTextOutput.textContent = data;
|
|
||||||
});
|
|
||||||
|
|
||||||
let dataSum = 0;
|
|
||||||
let lastByte = undefined;
|
|
||||||
let currentAudioID = "";
|
|
||||||
|
|
||||||
socket.on("openai-audio-start", (randomUUID) => {
|
|
||||||
console.log("OpenAI audio start:", randomUUID);
|
|
||||||
currentAudioID = randomUUID;
|
|
||||||
dataSum = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("openai-audio", async (data, randomUUID) => {
|
|
||||||
console.log("OpenAI audio received:", data, randomUUID);
|
|
||||||
if (currentAudioID !== randomUUID) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the received data
|
// Convert ArrayBuffer to Int16Array
|
||||||
console.log("Received data type:", data.constructor.name);
|
const int16Array = new Int16Array(data);
|
||||||
console.log("Received data:", data);
|
|
||||||
|
|
||||||
// Ensure data is an ArrayBuffer
|
// Normalize PCM16 data to the range [-1, 1]
|
||||||
if (!(data instanceof ArrayBuffer)) {
|
const float32Array = new Float32Array(int16Array.length);
|
||||||
console.error("Received data is not an ArrayBuffer");
|
for (let i = 0; i < int16Array.length; i++) {
|
||||||
return;
|
float32Array[i] = int16Array[i] / 0x7fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
dataSum += data.byteLength;
|
console.log("Normalized data:", float32Array.length);
|
||||||
console.log("Received data sum:", dataSum);
|
|
||||||
|
|
||||||
try {
|
// Add the normalized data to the audio queue
|
||||||
// Check if there was an odd byte from the previous chunk
|
for (let i = 0; i < float32Array.length; i++) {
|
||||||
if (lastByte !== undefined) {
|
audioQueue.push(float32Array[i]);
|
||||||
// Combine the last byte with the current data
|
|
||||||
const lastData = new Uint8Array(
|
|
||||||
lastByte.byteLength + data.byteLength
|
|
||||||
);
|
|
||||||
lastData.set(new Uint8Array(lastByte), 0);
|
|
||||||
lastData.set(new Uint8Array(data), lastByte.byteLength);
|
|
||||||
data = lastData;
|
|
||||||
lastByte = undefined; // Reset lastByte since it has been processed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the combined data has an odd number of bytes
|
|
||||||
if (data.byteLength % 2 !== 0) {
|
|
||||||
// Save the last byte for the next chunk
|
|
||||||
lastByte = data.slice(data.byteLength - 1);
|
|
||||||
// Remove the last byte from the current data
|
|
||||||
data = data.slice(0, data.byteLength - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert ArrayBuffer to Int16Array
|
|
||||||
const int16Array = new Int16Array(data);
|
|
||||||
|
|
||||||
// Normalize PCM16 data to the range [-1, 1]
|
|
||||||
const float32Array = new Float32Array(int16Array.length);
|
|
||||||
for (let i = 0; i < int16Array.length; i++) {
|
|
||||||
float32Array[i] = int16Array[i] / 0x7fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Normalized data:", float32Array.length);
|
|
||||||
|
|
||||||
// Add the normalized data to the audio queue
|
|
||||||
for (let i = 0; i < float32Array.length; i++) {
|
|
||||||
audioQueue.push(float32Array[i]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing audio data:", error);
|
|
||||||
}
|
}
|
||||||
});
|
} catch (error) {
|
||||||
|
console.error("Error processing audio data:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("bot-stopSpeaking", () => {
|
socket.on("bot-stopSpeaking", () => {
|
||||||
console.log("Bot stopped speaking");
|
console.log("Bot stopped speaking");
|
||||||
audioQueue = [];
|
stopAudio();
|
||||||
|
});
|
||||||
|
|
||||||
// Clear the audio queue fade out
|
function stopAudio() {
|
||||||
const fadeOut = setInterval(() => {
|
audioQueue = [];
|
||||||
if (audioQueue.length > 0) {
|
|
||||||
audioQueue = audioQueue.slice(0, audioQueue.length - 1);
|
// Clear the audio queue fade out
|
||||||
} else {
|
const fadeOut = setInterval(() => {
|
||||||
clearInterval(fadeOut);
|
if (audioQueue.length > 0) {
|
||||||
}
|
audioQueue = audioQueue.slice(0, audioQueue.length - 1);
|
||||||
}, 100);
|
} else {
|
||||||
});
|
clearInterval(fadeOut);
|
||||||
</script>
|
}
|
||||||
</body>
|
}, 100);
|
||||||
</html>
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1,9 @@
|
||||||
|
AI: Hallo hier ist Jan von Jannex, spreche ich hier mit Herr Mustermann?
|
||||||
|
User: ja babahaard
|
||||||
|
AI: Super, dass ich Sie erreiche! Ich rufe an, um kurz über KI-Möglichkeiten für Ihr Unternehmen zu sprechen. Hätten Sie kurz Zeit?
|
||||||
|
User: ja was gibt's denn
|
||||||
|
AI: Gibt es Bereiche bei Ihnen, wo Sie sich mehr Effizienz oder weniger Aufwand wünschen würden? KI kann da oft schon sehr hilfreich sein.
|
||||||
|
User: ja ich hab da zwar schon mal eine website gemacht aber
|
||||||
|
AI: Ah, ... **ABGEBROCHEN**
|
||||||
|
User: ich glaub jetzt haben wir jetzt nicht so wirklich mit dafür
|
||||||
|
AI: Total verständlich. Viele unserer Kunden waren anfangs auch skeptisch und haben trotzdem Potenziale gefunden. Ein kurzes Gespräch schadet nicht und könnte schon interessante Ansätze zeigen. Wie wäre es, wenn wir einfach mal ein KI-Audit machen, um mögliche Chancen auszuloten?
|
Loading…
Reference in New Issue