feat: OPD speech-to-text mic, medicine label enhancements & template search
- Add complaints Speech-to-Text (browser + server STT fallback) with interim preview - Add Medical Mic (server STT) for examination, diagnosis, provisional diagnosis fields - Add speech-test route + Home::speechTest() view for STT testing - OPD prescription: resolve dosage/when/frequency labels from lookup tables - OPD prescription: show generic/salt name below medicine name in print - OPD prescription: translate complaints/diagnosis/advice/investigation to Hindi for patient - Add translateToLocalPatientText() helper with English->Hindi medical term map - Add 'Remove All' medicines button (btn_clear_medicine) - Add template search box in Load Template modal - Fix investigation batch add: dedup + guard against concurrent add - Fix single investigation add: check duplicate before adding - Fix rx status text overflow: ellipsis + title tooltip - Remove unused FHIR History and Back to Invoice buttons from Rx header - .gitignore: exclude public/uploads/* and writable/ayushman/*
This commit is contained in:
parent
8e981cdf15
commit
17e2d8e16b
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -63,6 +63,8 @@ writable/uploads/*
|
||||||
writable/debugbar/*
|
writable/debugbar/*
|
||||||
!writable/debugbar/index.html
|
!writable/debugbar/index.html
|
||||||
|
|
||||||
|
writable/ayushman/*
|
||||||
|
|
||||||
php_errors.log
|
php_errors.log
|
||||||
|
|
||||||
#-------------------------
|
#-------------------------
|
||||||
|
|
@ -124,3 +126,6 @@ _modules/*
|
||||||
|
|
||||||
/results/
|
/results/
|
||||||
/phpunit*.xml
|
/phpunit*.xml
|
||||||
|
|
||||||
|
public/uploads/*
|
||||||
|
!public/uploads/.gitkeep
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ $routes->get('/', 'Home::index');
|
||||||
$routes->get('dashboard', 'Home::dashboard');
|
$routes->get('dashboard', 'Home::dashboard');
|
||||||
$routes->get('my-profile', 'Home::myProfile');
|
$routes->get('my-profile', 'Home::myProfile');
|
||||||
$routes->post('my-profile/save', 'Home::myProfileSave');
|
$routes->post('my-profile/save', 'Home::myProfileSave');
|
||||||
|
$routes->get('speech-test', 'Home::speechTest');
|
||||||
|
|
||||||
// Pharmacy (Medical) - phase 1
|
// Pharmacy (Medical) - phase 1
|
||||||
$routes->get('Medical', 'Medical::index');
|
$routes->get('Medical', 'Medical::index');
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,16 @@ class Home extends BaseController
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function speechTest()
|
||||||
|
{
|
||||||
|
$authRedirect = $this->ensureAuthenticated();
|
||||||
|
if ($authRedirect !== null) {
|
||||||
|
return $authRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('tools/speech_test');
|
||||||
|
}
|
||||||
|
|
||||||
public function myProfileSave()
|
public function myProfileSave()
|
||||||
{
|
{
|
||||||
$authRedirect = $this->ensureAuthenticated();
|
$authRedirect = $this->ensureAuthenticated();
|
||||||
|
|
|
||||||
|
|
@ -1745,14 +1745,21 @@ class Opd extends BaseController
|
||||||
$i = 0;
|
$i = 0;
|
||||||
foreach ($rxMeds as $med) {
|
foreach ($rxMeds as $med) {
|
||||||
$i++;
|
$i++;
|
||||||
$name = trim((string) ($med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? ''));
|
$name = trim((string) ($med['med_name'] ?? $med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? ''));
|
||||||
$dose = trim((string) ($med['drug_dose'] ?? $med['dose'] ?? ''));
|
$generic = trim((string) ($med['genericname'] ?? ($med['generic_name'] ?? ($med['salt_name'] ?? ''))));
|
||||||
$freq = trim((string) ($med['drug_freq'] ?? $med['frequency'] ?? ''));
|
$dose = trim((string) ($med['dosage_label'] ?? $med['dosage'] ?? $med['drug_dose'] ?? $med['dose'] ?? ''));
|
||||||
$days = trim((string) ($med['drug_day'] ?? $med['days'] ?? ''));
|
$freq = trim((string) ($med['dosage_freq_label'] ?? $med['dosage_freq'] ?? $med['drug_freq'] ?? $med['frequency'] ?? ''));
|
||||||
$inst = trim((string) ($med['drug_instruction'] ?? $med['instruction'] ?? ''));
|
$days = trim((string) ($med['no_of_days'] ?? $med['drug_day'] ?? $med['days'] ?? ''));
|
||||||
|
$when = trim((string) ($med['dosage_when_label'] ?? $med['dosage_when'] ?? ''));
|
||||||
|
$remark = trim((string) ($med['remark'] ?? ''));
|
||||||
|
$inst = trim(($when !== '' ? $when : '') . ($remark !== '' ? ($when !== '' ? ' | ' : '') . $remark : ''));
|
||||||
|
$nameHtml = esc($name);
|
||||||
|
if ($generic !== '') {
|
||||||
|
$nameHtml .= '<div style="font-size:10px;color:#444;">Salt/Generic: ' . esc($generic) . '</div>';
|
||||||
|
}
|
||||||
$medicalHtml .= '<tr>'
|
$medicalHtml .= '<tr>'
|
||||||
. '<td>' . $i . '</td>'
|
. '<td>' . $i . '</td>'
|
||||||
. '<td>' . esc($name) . '</td>'
|
. '<td>' . $nameHtml . '</td>'
|
||||||
. '<td>' . esc(trim($dose . ' ' . $freq)) . '</td>'
|
. '<td>' . esc(trim($dose . ' ' . $freq)) . '</td>'
|
||||||
. '<td>' . esc($days) . '</td>'
|
. '<td>' . esc($days) . '</td>'
|
||||||
. '<td>' . esc($inst) . '</td>'
|
. '<td>' . esc($inst) . '</td>'
|
||||||
|
|
@ -1785,6 +1792,26 @@ class Opd extends BaseController
|
||||||
$rxAdvice = implode(', ', $parts);
|
$rxAdvice = implode(', ', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$complaintText = (string) ($rx['complaints'] ?? '');
|
||||||
|
$diagnosisText = (string) (($rx['Provisional_diagnosis'] ?? '') !== '' ? ($rx['Provisional_diagnosis'] ?? '') : ($rx['diagnosis'] ?? ''));
|
||||||
|
$complaintLocal = $this->translateToLocalPatientText($complaintText);
|
||||||
|
$diagnosisLocal = $this->translateToLocalPatientText($diagnosisText);
|
||||||
|
$investigationLocal = $this->translateToLocalPatientText($rxInvestigation);
|
||||||
|
$rxAdviceLocal = $this->translateToLocalPatientText($rxAdvice);
|
||||||
|
|
||||||
|
if (strtolower(trim($complaintLocal)) === strtolower(trim($complaintText))) {
|
||||||
|
$complaintLocal = '';
|
||||||
|
}
|
||||||
|
if (strtolower(trim($diagnosisLocal)) === strtolower(trim($diagnosisText))) {
|
||||||
|
$diagnosisLocal = '';
|
||||||
|
}
|
||||||
|
if (strtolower(trim($investigationLocal)) === strtolower(trim($rxInvestigation))) {
|
||||||
|
$investigationLocal = '';
|
||||||
|
}
|
||||||
|
if (strtolower(trim($rxAdviceLocal)) === strtolower(trim($rxAdvice))) {
|
||||||
|
$rxAdviceLocal = '';
|
||||||
|
}
|
||||||
|
|
||||||
$bp = trim((string) ($rx['bp'] ?? ''));
|
$bp = trim((string) ($rx['bp'] ?? ''));
|
||||||
$diastolic = trim((string) ($rx['diastolic'] ?? ''));
|
$diastolic = trim((string) ($rx['diastolic'] ?? ''));
|
||||||
$pulse = trim((string) ($rx['pulse'] ?? ''));
|
$pulse = trim((string) ($rx['pulse'] ?? ''));
|
||||||
|
|
@ -1856,10 +1883,13 @@ class Opd extends BaseController
|
||||||
'total_no_visit' => $totalNoVisit,
|
'total_no_visit' => $totalNoVisit,
|
||||||
'last_opdvisit_date' => $lastVisitText,
|
'last_opdvisit_date' => $lastVisitText,
|
||||||
'str_opd_book_date' => $bookTime,
|
'str_opd_book_date' => $bookTime,
|
||||||
'Complaint' => (string) ($rx['complaints'] ?? ''),
|
'Complaint' => $complaintText,
|
||||||
'diagnosis' => (string) (($rx['Provisional_diagnosis'] ?? '') !== '' ? ($rx['Provisional_diagnosis'] ?? '') : ($rx['diagnosis'] ?? '')),
|
'Complaint_local' => $complaintLocal,
|
||||||
|
'diagnosis' => $diagnosisText,
|
||||||
|
'diagnosis_local' => $diagnosisLocal,
|
||||||
'Provisional_diagnosis' => (string) ($rx['Provisional_diagnosis'] ?? ''),
|
'Provisional_diagnosis' => (string) ($rx['Provisional_diagnosis'] ?? ''),
|
||||||
'investigation' => $rxInvestigation,
|
'investigation' => $rxInvestigation,
|
||||||
|
'investigation_local' => $investigationLocal,
|
||||||
'medical' => $medicalHtml,
|
'medical' => $medicalHtml,
|
||||||
'doctor' => '<p style="text-align:right;">Dr. ' . esc((string) ($opd->doc_name ?? '')) . '</p>',
|
'doctor' => '<p style="text-align:right;">Dr. ' . esc((string) ($opd->doc_name ?? '')) . '</p>',
|
||||||
'top_content' => '',
|
'top_content' => '',
|
||||||
|
|
@ -1867,6 +1897,7 @@ class Opd extends BaseController
|
||||||
'Finding_Examinations' => (string) ($rx['Finding_Examinations'] ?? ''),
|
'Finding_Examinations' => (string) ($rx['Finding_Examinations'] ?? ''),
|
||||||
'Prescriber_Remarks' => (string) ($rx['Prescriber_Remarks'] ?? ''),
|
'Prescriber_Remarks' => (string) ($rx['Prescriber_Remarks'] ?? ''),
|
||||||
'advice' => $rxAdvice,
|
'advice' => $rxAdvice,
|
||||||
|
'advice_local' => $rxAdviceLocal,
|
||||||
'next_visit' => (string) ($rx['next_visit'] ?? ''),
|
'next_visit' => (string) ($rx['next_visit'] ?? ''),
|
||||||
'refer_to' => (string) ($rx['refer_to'] ?? ''),
|
'refer_to' => (string) ($rx['refer_to'] ?? ''),
|
||||||
'painscale' => '',
|
'painscale' => '',
|
||||||
|
|
@ -1877,6 +1908,69 @@ class Opd extends BaseController
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function translateToLocalPatientText(string $input): string
|
||||||
|
{
|
||||||
|
$text = trim($input);
|
||||||
|
if ($text === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [
|
||||||
|
'before food' => 'भोजन से पहले',
|
||||||
|
'after food' => 'भोजन के बाद',
|
||||||
|
'after breakfast' => 'नाश्ते के बाद',
|
||||||
|
'before breakfast' => 'नाश्ते से पहले',
|
||||||
|
'after lunch' => 'दोपहर के भोजन के बाद',
|
||||||
|
'before lunch' => 'दोपहर के भोजन से पहले',
|
||||||
|
'after dinner' => 'रात के भोजन के बाद',
|
||||||
|
'before dinner' => 'रात के भोजन से पहले',
|
||||||
|
'morning' => 'सुबह',
|
||||||
|
'afternoon' => 'दोपहर',
|
||||||
|
'evening' => 'शाम',
|
||||||
|
'night' => 'रात',
|
||||||
|
'once daily' => 'दिन में एक बार',
|
||||||
|
'twice daily' => 'दिन में दो बार',
|
||||||
|
'thrice daily' => 'दिन में तीन बार',
|
||||||
|
'daily' => 'रोज',
|
||||||
|
'weekly' => 'साप्ताहिक',
|
||||||
|
'for' => 'के लिए',
|
||||||
|
'days' => 'दिन',
|
||||||
|
'day' => 'दिन',
|
||||||
|
'tab' => 'टैबलेट',
|
||||||
|
'cap' => 'कैप्सूल',
|
||||||
|
'syp' => 'सिरप',
|
||||||
|
'inj' => 'इंजेक्शन',
|
||||||
|
'apply' => 'लगाएं',
|
||||||
|
'continue' => 'जारी रखें',
|
||||||
|
'rest' => 'आराम करें',
|
||||||
|
'hydrate' => 'पर्याप्त पानी लें',
|
||||||
|
'avoid oily food' => 'तैलीय भोजन से बचें',
|
||||||
|
'avoid spicy food' => 'मसालेदार भोजन से बचें',
|
||||||
|
'follow up' => 'फॉलो-अप करें',
|
||||||
|
'review' => 'पुनः जांच',
|
||||||
|
'advice' => 'सलाह',
|
||||||
|
'investigation' => 'जांच',
|
||||||
|
'diagnosis' => 'निदान',
|
||||||
|
'fever' => 'बुखार',
|
||||||
|
'cough' => 'खांसी',
|
||||||
|
'cold' => 'जुकाम',
|
||||||
|
'headache' => 'सिर दर्द',
|
||||||
|
'abdominal pain' => 'पेट दर्द',
|
||||||
|
];
|
||||||
|
|
||||||
|
uksort($map, static function ($a, $b) {
|
||||||
|
return strlen((string) $b) <=> strlen((string) $a);
|
||||||
|
});
|
||||||
|
|
||||||
|
$translated = $text;
|
||||||
|
foreach ($map as $en => $local) {
|
||||||
|
$translated = preg_replace('/\b' . preg_quote($en, '/') . '\b/i', $local, $translated) ?? $translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$translated = preg_replace('/\s+/', ' ', trim($translated)) ?? trim($translated);
|
||||||
|
return $translated;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $vars
|
* @param array<string, mixed> $vars
|
||||||
*/
|
*/
|
||||||
|
|
@ -2221,6 +2315,11 @@ class Opd extends BaseController
|
||||||
$tokens['doctor_sign_html'] = nl2br((string) ($data['opd_master'][0]->doc_sign ?? ''));
|
$tokens['doctor_sign_html'] = nl2br((string) ($data['opd_master'][0]->doc_sign ?? ''));
|
||||||
$tokens['content'] = (string) ($tokens['content'] ?? '');
|
$tokens['content'] = (string) ($tokens['content'] ?? '');
|
||||||
|
|
||||||
|
$adviceLocal = trim((string) ($tokens['advice_local'] ?? ''));
|
||||||
|
$tokens['advice_local_line'] = $adviceLocal !== ''
|
||||||
|
? ('<br><strong>सलाह:</strong> ' . $adviceLocal)
|
||||||
|
: '';
|
||||||
|
|
||||||
return $tokens;
|
return $tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2499,6 +2598,115 @@ class Opd extends BaseController
|
||||||
$medBuilder->orderBy('id', 'ASC');
|
$medBuilder->orderBy('id', 'ASC');
|
||||||
}
|
}
|
||||||
$data['rx_medicines'] = $medBuilder->get()->getResultArray();
|
$data['rx_medicines'] = $medBuilder->get()->getResultArray();
|
||||||
|
|
||||||
|
if (!empty($data['rx_medicines'])) {
|
||||||
|
$mapDose = [];
|
||||||
|
$mapWhen = [];
|
||||||
|
$mapFreq = [];
|
||||||
|
$medGenericMap = [];
|
||||||
|
$missingGenericMedIds = [];
|
||||||
|
|
||||||
|
if ($this->db->tableExists('opd_dose_shed')) {
|
||||||
|
$f = $this->db->getFieldNames('opd_dose_shed');
|
||||||
|
$idF = $this->resolveFirstField($f, ['dose_shed_id', 'id']);
|
||||||
|
$labelF = $this->resolveFirstField($f, ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']);
|
||||||
|
if ($idF !== null && $labelF !== null) {
|
||||||
|
$rows = $this->db->table('opd_dose_shed')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray();
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$id = trim((string) ($r['id'] ?? ''));
|
||||||
|
$label = trim((string) ($r['label'] ?? ''));
|
||||||
|
if ($id !== '' && $label !== '') {
|
||||||
|
$mapDose[$id] = $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('opd_dose_when')) {
|
||||||
|
$f = $this->db->getFieldNames('opd_dose_when');
|
||||||
|
$idF = $this->resolveFirstField($f, ['dose_when_id', 'id']);
|
||||||
|
$labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']);
|
||||||
|
if ($idF !== null && $labelF !== null) {
|
||||||
|
$rows = $this->db->table('opd_dose_when')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray();
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$id = trim((string) ($r['id'] ?? ''));
|
||||||
|
$label = trim((string) ($r['label'] ?? ''));
|
||||||
|
if ($id !== '' && $label !== '') {
|
||||||
|
$mapWhen[$id] = $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('opd_dose_frequency')) {
|
||||||
|
$f = $this->db->getFieldNames('opd_dose_frequency');
|
||||||
|
$idF = $this->resolveFirstField($f, ['dose_freq_id', 'id']);
|
||||||
|
$labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']);
|
||||||
|
if ($idF !== null && $labelF !== null) {
|
||||||
|
$rows = $this->db->table('opd_dose_frequency')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray();
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$id = trim((string) ($r['id'] ?? ''));
|
||||||
|
$label = trim((string) ($r['label'] ?? ''));
|
||||||
|
if ($id !== '' && $label !== '') {
|
||||||
|
$mapFreq[$id] = $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data['rx_medicines'] as &$medRow) {
|
||||||
|
$doseRaw = trim((string) ($medRow['dosage'] ?? ''));
|
||||||
|
$whenRaw = trim((string) ($medRow['dosage_when'] ?? ''));
|
||||||
|
$freqRaw = trim((string) ($medRow['dosage_freq'] ?? ''));
|
||||||
|
$medRow['dosage_label'] = ($doseRaw !== '' && isset($mapDose[$doseRaw])) ? $mapDose[$doseRaw] : $doseRaw;
|
||||||
|
$medRow['dosage_when_label'] = ($whenRaw !== '' && isset($mapWhen[$whenRaw])) ? $mapWhen[$whenRaw] : $whenRaw;
|
||||||
|
$medRow['dosage_freq_label'] = ($freqRaw !== '' && isset($mapFreq[$freqRaw])) ? $mapFreq[$freqRaw] : $freqRaw;
|
||||||
|
|
||||||
|
$genericInline = trim((string) ($medRow['genericname'] ?? ($medRow['salt_name'] ?? '')));
|
||||||
|
if ($genericInline === '') {
|
||||||
|
$medId = (int) ($medRow['med_id'] ?? 0);
|
||||||
|
if ($medId > 0) {
|
||||||
|
$missingGenericMedIds[$medId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($medRow);
|
||||||
|
|
||||||
|
if (!empty($missingGenericMedIds) && $this->db->tableExists('opd_med_master')) {
|
||||||
|
$masterFields = $this->db->getFieldNames('opd_med_master');
|
||||||
|
$masterIdField = $this->resolveFirstField($masterFields, ['id']);
|
||||||
|
$masterGenericField = $this->resolveFirstField($masterFields, ['genericname', 'generic_name']);
|
||||||
|
$masterSaltField = $this->resolveFirstField($masterFields, ['salt_name', 'sal_name', 'salt', 'saltname']);
|
||||||
|
$labelField = $masterSaltField ?? $masterGenericField;
|
||||||
|
|
||||||
|
if ($masterIdField !== null && $labelField !== null) {
|
||||||
|
$rows = $this->db->table('opd_med_master')
|
||||||
|
->select($masterIdField . ' as med_id,' . $labelField . ' as generic_fallback')
|
||||||
|
->whereIn($masterIdField, array_map('intval', array_keys($missingGenericMedIds)))
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$id = (int) ($r['med_id'] ?? 0);
|
||||||
|
$label = trim((string) ($r['generic_fallback'] ?? ''));
|
||||||
|
if ($id > 0 && $label !== '') {
|
||||||
|
$medGenericMap[$id] = $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data['rx_medicines'] as &$medRow) {
|
||||||
|
$genericInline = trim((string) ($medRow['genericname'] ?? ($medRow['salt_name'] ?? '')));
|
||||||
|
if ($genericInline === '') {
|
||||||
|
$fallback = $medGenericMap[(int) ($medRow['med_id'] ?? 0)] ?? '';
|
||||||
|
if ($fallback !== '') {
|
||||||
|
$medRow['genericname'] = $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($medRow);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$data['rx_medicines'] = [];
|
$data['rx_medicines'] = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,20 @@
|
||||||
}
|
}
|
||||||
.rx-save-status {
|
.rx-save-status {
|
||||||
font-size: .875rem;
|
font-size: .875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 320px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#rx_status_text {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.rx-tab-btn.active {
|
.rx-tab-btn.active {
|
||||||
background: #0d6efd;
|
background: #0d6efd;
|
||||||
|
|
@ -123,6 +137,56 @@
|
||||||
float: right;
|
float: right;
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
}
|
}
|
||||||
|
.rx-label-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
.rx-label-actions .rx-label-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #344054;
|
||||||
|
}
|
||||||
|
.rx-complaint-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
.rx-complaint-actions .rx-ai-btn {
|
||||||
|
float: none;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.btn-complaints-rewrite {
|
||||||
|
border-color: #2b6cb0;
|
||||||
|
color: #2b6cb0;
|
||||||
|
background: #eef6ff;
|
||||||
|
}
|
||||||
|
.btn-complaints-rewrite:hover {
|
||||||
|
background: #2b6cb0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-complaints-mic {
|
||||||
|
border-color: #13795b;
|
||||||
|
color: #13795b;
|
||||||
|
background: #ecfdf3;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.btn-medical-stt {
|
||||||
|
border-color: #0f766e;
|
||||||
|
color: #0f766e;
|
||||||
|
background: #ecfeff;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.btn-medical-stt:hover {
|
||||||
|
background: #0f766e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-complaints-mic:hover {
|
||||||
|
background: #13795b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.rx-scan-banner {
|
.rx-scan-banner {
|
||||||
display: none;
|
display: none;
|
||||||
border: 1px solid #b6d4fe;
|
border: 1px solid #b6d4fe;
|
||||||
|
|
@ -222,8 +286,6 @@
|
||||||
<li><button type="button" class="dropdown-item text-warning" id="btn_print2">Print Rx With OPD Head LetterHead</button></li>
|
<li><button type="button" class="dropdown-item text-warning" id="btn_print2">Print Rx With OPD Head LetterHead</button></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_toggle_fhir_history" title="Show/Hide FHIR Export History">📄 FHIR History</button>
|
|
||||||
<a href="javascript:load_form('<?= base_url('Opd/invoice') ?>/<?= esc($opd_id) ?>','OPD Invoice');" class="btn btn-secondary btn-sm">Back to Invoice</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="rx_scan_banner" class="rx-scan-banner"></div>
|
<div id="rx_scan_banner" class="rx-scan-banner"></div>
|
||||||
|
|
@ -499,18 +561,19 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Complaints
|
<label class="form-label">Complaints
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm rx-ai-btn btn-ai-rewrite" data-target="complaints" data-mode="hinglish_to_english">Hinglish -> English</button>
|
<button type="button" class="btn btn-outline-primary btn-sm rx-ai-btn btn-ai-rewrite btn-complaints-rewrite" data-target="complaints" data-mode="hinglish_to_english" title="Convert Hinglish text to English">↔ Hinglish → English</button>
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm rx-ai-btn" id="btn_complaints_mic" title="Speech to text for complaints">Mic</button>
|
||||||
</label>
|
</label>
|
||||||
<textarea class="form-control rx-field" id="complaints" rows="4" maxlength="4000"><?= esc($opd_prescription[0]->complaints ?? '') ?></textarea>
|
<textarea class="form-control rx-field" id="complaints" rows="4" maxlength="4000"><?= esc($opd_prescription[0]->complaints ?? '') ?></textarea>
|
||||||
|
<div id="complaints_interim_preview" style="display:none;font-size:.8rem;color:#6c757d;padding:2px 4px;font-style:italic;"></div>
|
||||||
<div class="rx-counter" id="counter_complaints">0/4000</div>
|
<div class="rx-counter" id="counter_complaints">0/4000</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Examination
|
<label class="form-label">Examination
|
||||||
</label>
|
</label>
|
||||||
<div class="small text-muted mb-1">AI Assist is optional. Use it only if you want cleaner clinical wording; manual typing works as usual.</div>
|
|
||||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="finding_examinations" data-mode="finding">AI Assist</button>
|
<button type="button" class="btn btn-sm btn-medical-stt" data-target="finding_examinations" data-label="🎙 Med Mic">🎙 Med Mic</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="finding_examinations" data-target="finding_examinations">Past Data</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="finding_examinations" data-target="finding_examinations">Past Data</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="finding_examinations" data-target="finding_examinations">Load Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="finding_examinations" data-target="finding_examinations">Load Template</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="finding_examinations" data-target="finding_examinations">Save as Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="finding_examinations" data-target="finding_examinations">Save as Template</button>
|
||||||
|
|
@ -524,7 +587,7 @@
|
||||||
<label class="form-label">Diagnosis
|
<label class="form-label">Diagnosis
|
||||||
</label>
|
</label>
|
||||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="diagnosis" data-mode="diagnosis">AI Assist</button>
|
<button type="button" class="btn btn-sm btn-medical-stt" data-target="diagnosis" data-label="🎙 Med Mic">🎙 Med Mic</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="diagnosis" data-target="diagnosis">Past Data</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="diagnosis" data-target="diagnosis">Past Data</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="diagnosis" data-target="diagnosis">Load Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="diagnosis" data-target="diagnosis">Load Template</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="diagnosis" data-target="diagnosis">Save as Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="diagnosis" data-target="diagnosis">Save as Template</button>
|
||||||
|
|
@ -538,7 +601,7 @@
|
||||||
<label class="form-label">Provisional Diagnosis
|
<label class="form-label">Provisional Diagnosis
|
||||||
</label>
|
</label>
|
||||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="provisional_diagnosis" data-mode="diagnosis">AI Assist</button>
|
<button type="button" class="btn btn-sm btn-medical-stt" data-target="provisional_diagnosis" data-label="🎙 Med Mic">🎙 Med Mic</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Past Data</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Past Data</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Load Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Load Template</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Save as Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Save as Template</button>
|
||||||
|
|
@ -552,7 +615,6 @@
|
||||||
<label class="form-label">Prescription
|
<label class="form-label">Prescription
|
||||||
</label>
|
</label>
|
||||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="prescriber_remarks" data-mode="autotype">AI Assist</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="prescriber_remarks" data-target="prescriber_remarks">Past Data</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="prescriber_remarks" data-target="prescriber_remarks">Past Data</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="prescriber_remarks" data-target="prescriber_remarks">Load Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="prescriber_remarks" data-target="prescriber_remarks">Load Template</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="prescriber_remarks" data-target="prescriber_remarks">Save as Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="prescriber_remarks" data-target="prescriber_remarks">Save as Template</button>
|
||||||
|
|
@ -572,7 +634,6 @@
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label"><strong>Advice Add</strong></label>
|
<label class="form-label"><strong>Advice Add</strong></label>
|
||||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="advice" data-mode="autotype">AI Assist</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="advice" data-target="advice">Past Data</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="advice" data-target="advice">Past Data</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="advice" data-target="advice">Load Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="advice" data-target="advice">Load Template</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="advice" data-target="advice">Save as Template</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="advice" data-target="advice">Save as Template</button>
|
||||||
|
|
@ -717,6 +778,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 d-grid gap-2">
|
<div class="col-md-2 d-grid gap-2">
|
||||||
<button type="button" class="btn btn-primary" id="btn_add_medicine">Add</button>
|
<button type="button" class="btn btn-primary" id="btn_add_medicine">Add</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" id="btn_clear_medicine">Remove All</button>
|
||||||
<button type="button" class="btn btn-outline-secondary" id="btn_cancel_medicine_edit" style="display:none;">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" id="btn_cancel_medicine_edit" style="display:none;">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -777,6 +839,8 @@
|
||||||
<div class="d-flex flex-wrap gap-1 mb-2" id="rx_template_suggest_box">
|
<div class="d-flex flex-wrap gap-1 mb-2" id="rx_template_suggest_box">
|
||||||
<span class="text-muted small">No suggestion available.</span>
|
<span class="text-muted small">No suggestion available.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="form-label">Search Template</label>
|
||||||
|
<input type="text" class="form-control form-control-sm mb-2" id="rx_template_search" placeholder="Search by name or content...">
|
||||||
<label class="form-label">Select Template</label>
|
<label class="form-label">Select Template</label>
|
||||||
<select class="form-select form-select-sm" id="rx_template_select"></select>
|
<select class="form-select form-select-sm" id="rx_template_select"></select>
|
||||||
<label class="form-label mt-2">Apply Mode</label>
|
<label class="form-label mt-2">Apply Mode</label>
|
||||||
|
|
@ -883,6 +947,20 @@
|
||||||
var latestLocalAssist = null;
|
var latestLocalAssist = null;
|
||||||
var complaintSuggestions = [];
|
var complaintSuggestions = [];
|
||||||
var selectedComplaints = [];
|
var selectedComplaints = [];
|
||||||
|
var complaintsMicMode = 'off';
|
||||||
|
var complaintsSpeechRecognition = null;
|
||||||
|
var complaintsMicActive = false;
|
||||||
|
var complaintsMediaRecorder = null;
|
||||||
|
var complaintsMediaStream = null;
|
||||||
|
var complaintsMediaChunks = [];
|
||||||
|
var complaintsBaseText = '';
|
||||||
|
var medicalSttTarget = '';
|
||||||
|
var medicalSttRecorder = null;
|
||||||
|
var medicalSttStream = null;
|
||||||
|
var medicalSttChunks = [];
|
||||||
|
var medicalSttActive = false;
|
||||||
|
var complaintsSttServerHealthUrl = 'http://139.59.13.39:8000/health';
|
||||||
|
var complaintsSttServerTranscribeUrl = 'http://139.59.13.39:8000/stt/transcribe';
|
||||||
var draftKey = 'opd_rx_draft_' + ($('#opd_id').val() || '0');
|
var draftKey = 'opd_rx_draft_' + ($('#opd_id').val() || '0');
|
||||||
var templateLoadState = { target: '', section: '', rows: [] };
|
var templateLoadState = { target: '', section: '', rows: [] };
|
||||||
var templateSaveState = { section: '', text: '', scope: 'doctor' };
|
var templateSaveState = { section: '', text: '', scope: 'doctor' };
|
||||||
|
|
@ -894,6 +972,7 @@
|
||||||
var invCustomProfileKey = 'opd_inv_custom_profiles';
|
var invCustomProfileKey = 'opd_inv_custom_profiles';
|
||||||
var legacyInvestigationProfiles = {};
|
var legacyInvestigationProfiles = {};
|
||||||
var legacyInvestigationProfileList = [];
|
var legacyInvestigationProfileList = [];
|
||||||
|
var investigationBatchActive = false;
|
||||||
var activeMedicineScope = 'active';
|
var activeMedicineScope = 'active';
|
||||||
var medicineSuggestRows = [];
|
var medicineSuggestRows = [];
|
||||||
var investigationProfiles = {
|
var investigationProfiles = {
|
||||||
|
|
@ -950,6 +1029,421 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setComplaintsMicMode(mode, note) {
|
||||||
|
complaintsMicMode = mode;
|
||||||
|
var $btn = $('#btn_complaints_mic');
|
||||||
|
if (!$btn.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$btn.removeClass('btn-outline-success btn-outline-primary btn-outline-secondary btn-danger');
|
||||||
|
|
||||||
|
if (mode === 'server') {
|
||||||
|
$btn.addClass('btn-outline-success').prop('disabled', false).text('🎙 Server Mic');
|
||||||
|
} else if (mode === 'browser') {
|
||||||
|
$btn.addClass('btn-outline-primary').prop('disabled', false).text('🎤 Browser Mic');
|
||||||
|
} else {
|
||||||
|
$btn.addClass('btn-outline-secondary').prop('disabled', true).text('⏸ Mic Off');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
$('.jsError').removeClass('text-danger text-success').addClass('text-muted').text(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setComplaintsMicListening(listening) {
|
||||||
|
var $btn = $('#btn_complaints_mic');
|
||||||
|
if (!$btn.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
complaintsMicActive = listening;
|
||||||
|
if (listening) {
|
||||||
|
$btn.removeClass('btn-outline-success btn-outline-primary btn-outline-secondary').addClass('btn-danger').text('⏹ Stop Mic');
|
||||||
|
} else {
|
||||||
|
setComplaintsMicMode(complaintsMicMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTranscriptToComplaints(text) {
|
||||||
|
var transcript = (text || '').toString().trim();
|
||||||
|
if (!transcript) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = ($('#complaints').val() || '').toString().trim();
|
||||||
|
var nextVal = current ? (current + '\n' + transcript) : transcript;
|
||||||
|
$('#complaints').val(nextVal);
|
||||||
|
refreshCounters();
|
||||||
|
markDirty('Speech text added in complaints');
|
||||||
|
scheduleAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTranscriptToField(targetId, text) {
|
||||||
|
var transcript = (text || '').toString().trim();
|
||||||
|
if (!transcript || !targetId || !$('#' + targetId).length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = ($('#' + targetId).val() || '').toString().trim();
|
||||||
|
var nextVal = current ? (current + '\n' + transcript) : transcript;
|
||||||
|
$('#' + targetId).val(nextVal);
|
||||||
|
refreshCounters();
|
||||||
|
markDirty('Medical speech text added');
|
||||||
|
scheduleAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMedicalContextForField(targetId) {
|
||||||
|
if (targetId === 'finding_examinations') {
|
||||||
|
return 'examination findings';
|
||||||
|
}
|
||||||
|
if (targetId === 'diagnosis') {
|
||||||
|
return 'diagnosis notes';
|
||||||
|
}
|
||||||
|
if (targetId === 'provisional_diagnosis') {
|
||||||
|
return 'provisional diagnosis notes';
|
||||||
|
}
|
||||||
|
return 'clinical notes';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMedicalMicButtonState(activeTarget) {
|
||||||
|
$('.btn-medical-stt').each(function() {
|
||||||
|
var $btn = $(this);
|
||||||
|
var target = ($btn.data('target') || '').toString();
|
||||||
|
var baseText = ($btn.data('label') || '🎙 Med Mic').toString();
|
||||||
|
|
||||||
|
$btn.removeClass('btn-danger').addClass('btn-medical-stt').prop('disabled', false).text(baseText);
|
||||||
|
|
||||||
|
if (activeTarget && target === activeTarget) {
|
||||||
|
$btn.removeClass('btn-medical-stt').addClass('btn-danger').text('⏹ Stop Mic');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMedicalSttStream() {
|
||||||
|
if (medicalSttStream && medicalSttStream.getTracks) {
|
||||||
|
medicalSttStream.getTracks().forEach(function(track) {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
medicalSttStream = null;
|
||||||
|
medicalSttRecorder = null;
|
||||||
|
medicalSttChunks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMedicalAudioToServer(audioBlob, targetId) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('audio', audioBlob, (targetId || 'clinical') + '.webm');
|
||||||
|
formData.append('lang', 'en-IN');
|
||||||
|
formData.append('medical_context', getMedicalContextForField(targetId));
|
||||||
|
|
||||||
|
return fetch(complaintsSttServerTranscribeUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
mode: 'cors',
|
||||||
|
body: formData
|
||||||
|
}).then(function(response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.json().catch(function() {
|
||||||
|
return {};
|
||||||
|
}).then(function(payload) {
|
||||||
|
var err = new Error(payload.detail || ('Server response: ' + response.status));
|
||||||
|
err.statusCode = response.status;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}).then(function(payload) {
|
||||||
|
var text = String((payload && (payload.text || payload.transcript || payload.result)) || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Empty transcript from server');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMedicalServerStt(targetId) {
|
||||||
|
if (!targetId || !$('#' + targetId).length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech is not available in this browser.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (medicalSttActive && medicalSttRecorder) {
|
||||||
|
if (medicalSttTarget === targetId) {
|
||||||
|
medicalSttRecorder.stop();
|
||||||
|
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Processing medical transcript...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical mic is already recording another field. Stop it first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
|
||||||
|
medicalSttTarget = targetId;
|
||||||
|
medicalSttStream = stream;
|
||||||
|
medicalSttRecorder = new MediaRecorder(stream);
|
||||||
|
medicalSttChunks = [];
|
||||||
|
|
||||||
|
medicalSttRecorder.ondataavailable = function(event) {
|
||||||
|
if (event.data && event.data.size > 0) {
|
||||||
|
medicalSttChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
medicalSttRecorder.onstop = function() {
|
||||||
|
var blob = new Blob(medicalSttChunks, {
|
||||||
|
type: (medicalSttRecorder && medicalSttRecorder.mimeType) ? medicalSttRecorder.mimeType : 'audio/webm'
|
||||||
|
});
|
||||||
|
var targetForTranscript = medicalSttTarget;
|
||||||
|
|
||||||
|
stopMedicalSttStream();
|
||||||
|
medicalSttActive = false;
|
||||||
|
medicalSttTarget = '';
|
||||||
|
setMedicalMicButtonState('');
|
||||||
|
|
||||||
|
sendMedicalAudioToServer(blob, targetForTranscript).then(function(transcript) {
|
||||||
|
appendTranscriptToField(targetForTranscript, transcript);
|
||||||
|
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Medical speech text added.');
|
||||||
|
}).catch(function(error) {
|
||||||
|
if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech input error: ' + (error.message || 'Invalid request'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech server unavailable. Please try again.');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
medicalSttActive = true;
|
||||||
|
setMedicalMicButtonState(targetId);
|
||||||
|
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Recording medical speech... click same Mic to stop.');
|
||||||
|
medicalSttRecorder.start();
|
||||||
|
}).catch(function() {
|
||||||
|
medicalSttActive = false;
|
||||||
|
medicalSttTarget = '';
|
||||||
|
setMedicalMicButtonState('');
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Microphone permission denied for medical speech.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkComplaintsSttServer() {
|
||||||
|
if (!window.fetch || !window.AbortController) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var controller = new AbortController();
|
||||||
|
var timeoutId = window.setTimeout(function() {
|
||||||
|
controller.abort();
|
||||||
|
}, 3500);
|
||||||
|
|
||||||
|
return fetch(complaintsSttServerHealthUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
mode: 'cors',
|
||||||
|
signal: controller.signal
|
||||||
|
}).then(function(response) {
|
||||||
|
return !!(response && response.ok);
|
||||||
|
}).catch(function() {
|
||||||
|
return false;
|
||||||
|
}).finally(function() {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendComplaintsAudioToServer(audioBlob) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('audio', audioBlob, 'complaints.webm');
|
||||||
|
formData.append('lang', 'en-IN');
|
||||||
|
formData.append('medical_context', (selectedComplaints || []).join(', '));
|
||||||
|
|
||||||
|
return fetch(complaintsSttServerTranscribeUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
mode: 'cors',
|
||||||
|
body: formData
|
||||||
|
}).then(function(response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.json().catch(function() {
|
||||||
|
return {};
|
||||||
|
}).then(function(payload) {
|
||||||
|
var err = new Error(payload.detail || ('Server response: ' + response.status));
|
||||||
|
err.statusCode = response.status;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}).then(function(payload) {
|
||||||
|
var text = String((payload && (payload.text || payload.transcript || payload.result)) || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Empty transcript from server');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopComplaintsMediaStream() {
|
||||||
|
if (complaintsMediaStream && complaintsMediaStream.getTracks) {
|
||||||
|
complaintsMediaStream.getTracks().forEach(function(track) {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
complaintsMediaStream = null;
|
||||||
|
complaintsMediaRecorder = null;
|
||||||
|
complaintsMediaChunks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startComplaintsBrowserStt() {
|
||||||
|
if (!complaintsSpeechRecognition) {
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Browser speech recognition is not available.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complaintsMicActive) {
|
||||||
|
complaintsSpeechRecognition.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setComplaintsMicListening(true);
|
||||||
|
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Listening complaints (browser mode)...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
complaintsSpeechRecognition.start();
|
||||||
|
} catch (e) {
|
||||||
|
setComplaintsMicListening(false);
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Could not start browser speech recognition.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startComplaintsServerStt() {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||||
|
if (complaintsSpeechRecognition) {
|
||||||
|
setComplaintsMicMode('browser', 'Server recording is unavailable. Switched to browser mode.');
|
||||||
|
startComplaintsBrowserStt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setComplaintsMicMode('off', 'Speech is not available in this browser.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complaintsMicActive && complaintsMediaRecorder) {
|
||||||
|
complaintsMediaRecorder.stop();
|
||||||
|
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Processing server transcript...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
|
||||||
|
complaintsMediaStream = stream;
|
||||||
|
complaintsMediaRecorder = new MediaRecorder(stream);
|
||||||
|
complaintsMediaChunks = [];
|
||||||
|
|
||||||
|
complaintsMediaRecorder.ondataavailable = function(event) {
|
||||||
|
if (event.data && event.data.size > 0) {
|
||||||
|
complaintsMediaChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
complaintsMediaRecorder.onstop = function() {
|
||||||
|
var blob = new Blob(complaintsMediaChunks, {
|
||||||
|
type: (complaintsMediaRecorder && complaintsMediaRecorder.mimeType) ? complaintsMediaRecorder.mimeType : 'audio/webm'
|
||||||
|
});
|
||||||
|
|
||||||
|
stopComplaintsMediaStream();
|
||||||
|
setComplaintsMicListening(false);
|
||||||
|
|
||||||
|
sendComplaintsAudioToServer(blob).then(function(transcript) {
|
||||||
|
appendTranscriptToComplaints(transcript);
|
||||||
|
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Complaints speech text added.');
|
||||||
|
}).catch(function(error) {
|
||||||
|
if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Speech input error: ' + (error.message || 'Invalid request'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (complaintsSpeechRecognition) {
|
||||||
|
setComplaintsMicMode('browser', 'Server STT unavailable. Switched to browser mode.');
|
||||||
|
} else {
|
||||||
|
setComplaintsMicMode('off', 'Server STT unavailable and browser speech not supported.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setComplaintsMicListening(true);
|
||||||
|
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Recording complaints... click Mic again to stop.');
|
||||||
|
complaintsMediaRecorder.start();
|
||||||
|
}).catch(function() {
|
||||||
|
if (complaintsSpeechRecognition) {
|
||||||
|
setComplaintsMicMode('browser', 'Microphone permission failed for server mode. Switched to browser mode.');
|
||||||
|
} else {
|
||||||
|
setComplaintsMicMode('off', 'Microphone permission denied.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initComplaintsSpeech() {
|
||||||
|
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (SpeechRecognition) {
|
||||||
|
complaintsSpeechRecognition = new SpeechRecognition();
|
||||||
|
complaintsSpeechRecognition.lang = 'en-IN';
|
||||||
|
complaintsSpeechRecognition.interimResults = true;
|
||||||
|
complaintsSpeechRecognition.continuous = false;
|
||||||
|
|
||||||
|
complaintsSpeechRecognition.onresult = function(event) {
|
||||||
|
var interimTranscript = '';
|
||||||
|
var finalTranscript = '';
|
||||||
|
for (var i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
var t = event.results[i][0].transcript;
|
||||||
|
if (event.results[i].isFinal) {
|
||||||
|
finalTranscript += t;
|
||||||
|
} else {
|
||||||
|
interimTranscript += t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interimTranscript) {
|
||||||
|
// Show interim as a non-destructive preview below the textarea
|
||||||
|
$('#complaints_interim_preview').text('🎤 ' + interimTranscript).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalTranscript.trim()) {
|
||||||
|
// Final: clear preview, append once to the base text
|
||||||
|
$('#complaints_interim_preview').hide().text('');
|
||||||
|
appendTranscriptToComplaints(finalTranscript.trim());
|
||||||
|
complaintsBaseText = ($('#complaints').val() || '').toString().trim();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
complaintsSpeechRecognition.onerror = function(event) {
|
||||||
|
setComplaintsMicListening(false);
|
||||||
|
$('#complaints_interim_preview').hide().text('');
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Browser speech error: ' + (event.error || 'unknown'));
|
||||||
|
};
|
||||||
|
|
||||||
|
complaintsSpeechRecognition.onend = function() {
|
||||||
|
setComplaintsMicListening(false);
|
||||||
|
$('#complaints_interim_preview').hide().text('');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complaintsSpeechRecognition) {
|
||||||
|
setComplaintsMicMode('browser', 'Complaints Mic is ready (browser mode for local language).');
|
||||||
|
} else {
|
||||||
|
setComplaintsMicMode('off', 'Complaints Mic unavailable in this browser.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#btn_complaints_mic').on('click', function() {
|
||||||
|
if (complaintsMicMode === 'browser') {
|
||||||
|
startComplaintsBrowserStt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMedicalSpeechButtons() {
|
||||||
|
setMedicalMicButtonState('');
|
||||||
|
$(document).on('click', '.btn-medical-stt', function() {
|
||||||
|
var targetId = ($(this).data('target') || '').toString();
|
||||||
|
startMedicalServerStt(targetId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getCustomInvestigationProfiles() {
|
function getCustomInvestigationProfiles() {
|
||||||
try {
|
try {
|
||||||
var parsed = JSON.parse(localStorage.getItem(invCustomProfileKey) || '[]');
|
var parsed = JSON.parse(localStorage.getItem(invCustomProfileKey) || '[]');
|
||||||
|
|
@ -1104,6 +1598,7 @@
|
||||||
function setStatus(type, message) {
|
function setStatus(type, message) {
|
||||||
var $badge = $('#rx_status_badge');
|
var $badge = $('#rx_status_badge');
|
||||||
var $text = $('#rx_status_text');
|
var $text = $('#rx_status_text');
|
||||||
|
var statusMessage = (message || '').toString();
|
||||||
$badge.removeClass('bg-secondary bg-warning bg-success bg-danger');
|
$badge.removeClass('bg-secondary bg-warning bg-success bg-danger');
|
||||||
|
|
||||||
if (type === 'saved') {
|
if (type === 'saved') {
|
||||||
|
|
@ -1118,7 +1613,7 @@
|
||||||
$badge.addClass('bg-secondary').text('Not Saved');
|
$badge.addClass('bg-secondary').text('Not Saved');
|
||||||
}
|
}
|
||||||
|
|
||||||
$text.text(message || '');
|
$text.text(statusMessage).attr('title', statusMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScanBanner() {
|
function updateScanBanner() {
|
||||||
|
|
@ -1879,6 +2374,40 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTemplateSelectOptions(searchQuery) {
|
||||||
|
var list = Array.isArray(templateLoadState.rows) ? templateLoadState.rows : [];
|
||||||
|
var query = (searchQuery || '').toString().trim().toLowerCase();
|
||||||
|
var $sel = $('#rx_template_select');
|
||||||
|
$sel.empty();
|
||||||
|
|
||||||
|
var added = 0;
|
||||||
|
list.forEach(function(row, idx) {
|
||||||
|
var name = (row.template_name || ('Template ' + (idx + 1))).toString();
|
||||||
|
var src = parseInt(row.doc_id || '0', 10) === 0 ? '[Master] ' : '[My] ';
|
||||||
|
var previewText = (row.template_text || '').toString();
|
||||||
|
var haystack = (name + ' ' + previewText + ' ' + src).toLowerCase();
|
||||||
|
if (query && haystack.indexOf(query) === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$sel.append('<option value="' + idx + '">' + $('<div>').text(src + name).html() + '</option>');
|
||||||
|
added++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!added) {
|
||||||
|
$sel.append('<option value="">No templates match search</option>');
|
||||||
|
$('#rx_template_preview').val('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstVal = ($sel.find('option:first').val() || '').toString();
|
||||||
|
var firstIdx = parseInt(firstVal, 10);
|
||||||
|
if (!isNaN(firstIdx) && firstIdx >= 0) {
|
||||||
|
$sel.val(String(firstIdx));
|
||||||
|
var firstRow = list[firstIdx] || {};
|
||||||
|
$('#rx_template_preview').val(firstRow.template_text || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$(document).on('click', '.btn-template-load', function() {
|
$(document).on('click', '.btn-template-load', function() {
|
||||||
var target = ($(this).data('target') || '').toString();
|
var target = ($(this).data('target') || '').toString();
|
||||||
var section = ($(this).data('section') || '').toString();
|
var section = ($(this).data('section') || '').toString();
|
||||||
|
|
@ -1896,22 +2425,14 @@
|
||||||
|
|
||||||
var rankedRows = rankTemplateRows(rows, target);
|
var rankedRows = rankTemplateRows(rows, target);
|
||||||
templateLoadState = { target: target, section: section, rows: rankedRows };
|
templateLoadState = { target: target, section: section, rows: rankedRows };
|
||||||
var $sel = $('#rx_template_select');
|
$('#rx_template_search').val('');
|
||||||
$sel.empty();
|
renderTemplateSelectOptions('');
|
||||||
rankedRows.forEach(function(row, idx) {
|
|
||||||
var name = row.template_name || ('Template ' + (idx + 1));
|
|
||||||
var src = parseInt(row.doc_id || '0', 10) === 0 ? '[Master] ' : '[My] ';
|
|
||||||
$sel.append('<option value="' + idx + '">' + $('<div>').text(src + name).html() + '</option>');
|
|
||||||
});
|
|
||||||
|
|
||||||
renderTemplateQuickSuggestions(rankedRows);
|
renderTemplateQuickSuggestions(rankedRows);
|
||||||
|
|
||||||
var existingText = ($('#' + target).val() || '').toString().trim();
|
var existingText = ($('#' + target).val() || '').toString().trim();
|
||||||
$('#rx_template_apply_mode').val(existingText ? 'append' : 'replace');
|
$('#rx_template_apply_mode').val(existingText ? 'append' : 'replace');
|
||||||
|
|
||||||
var firstText = (rankedRows[0] && rankedRows[0].template_text) ? rankedRows[0].template_text : '';
|
|
||||||
$('#rx_template_preview').val(firstText);
|
|
||||||
|
|
||||||
if (window.bootstrap && window.bootstrap.Modal) {
|
if (window.bootstrap && window.bootstrap.Modal) {
|
||||||
window.bootstrap.Modal.getOrCreateInstance(document.getElementById('rxTemplateModal')).show();
|
window.bootstrap.Modal.getOrCreateInstance(document.getElementById('rxTemplateModal')).show();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1921,11 +2442,19 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#rx_template_select').on('change', function() {
|
$('#rx_template_select').on('change', function() {
|
||||||
var idx = parseInt($(this).val() || '0', 10);
|
var idx = parseInt($(this).val() || '-1', 10);
|
||||||
|
if (isNaN(idx) || idx < 0) {
|
||||||
|
$('#rx_template_preview').val('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
var row = (templateLoadState.rows || [])[idx] || {};
|
var row = (templateLoadState.rows || [])[idx] || {};
|
||||||
$('#rx_template_preview').val(row.template_text || '');
|
$('#rx_template_preview').val(row.template_text || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#rx_template_search').on('input', function() {
|
||||||
|
renderTemplateSelectOptions($(this).val() || '');
|
||||||
|
});
|
||||||
|
|
||||||
$('#btn_apply_template_choice').on('click', function() {
|
$('#btn_apply_template_choice').on('click', function() {
|
||||||
var idx = parseInt($('#rx_template_select').val() || '0', 10);
|
var idx = parseInt($('#rx_template_select').val() || '0', 10);
|
||||||
var row = (templateLoadState.rows || [])[idx] || null;
|
var row = (templateLoadState.rows || [])[idx] || null;
|
||||||
|
|
@ -1976,6 +2505,10 @@
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!$('#rx_template_select option[value="' + idx + '"]').length) {
|
||||||
|
$('#rx_template_search').val('');
|
||||||
|
renderTemplateSelectOptions('');
|
||||||
|
}
|
||||||
$('#rx_template_select').val(String(idx)).trigger('change');
|
$('#rx_template_select').val(String(idx)).trigger('change');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2255,6 +2788,8 @@
|
||||||
refreshCounters();
|
refreshCounters();
|
||||||
setStatus('normal', 'No local changes');
|
setStatus('normal', 'No local changes');
|
||||||
renderComplaintChips();
|
renderComplaintChips();
|
||||||
|
initComplaintsSpeech();
|
||||||
|
initMedicalSpeechButtons();
|
||||||
setTimeout(loadPatientScanHistory, 50);
|
setTimeout(loadPatientScanHistory, 50);
|
||||||
|
|
||||||
$(document).on('click', '.btn-ai-rewrite', function() {
|
$(document).on('click', '.btn-ai-rewrite', function() {
|
||||||
|
|
@ -2620,6 +3155,10 @@
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeInvestigationKey(name) {
|
||||||
|
return (name || '').toString().trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function addInvestigationEntry(name, code, done) {
|
function addInvestigationEntry(name, code, done) {
|
||||||
name = (name || '').trim();
|
name = (name || '').trim();
|
||||||
code = (code || '').trim();
|
code = (code || '').trim();
|
||||||
|
|
@ -2669,10 +3208,23 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (investigationBatchActive) {
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Please wait, previous profile is still being added.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var existing = getExistingInvestigationNames();
|
var existing = getExistingInvestigationNames();
|
||||||
|
var localSeen = {};
|
||||||
var queue = rows.filter(function(row) {
|
var queue = rows.filter(function(row) {
|
||||||
var key = ((row && row.name) ? row.name : '').toString().trim().toLowerCase();
|
var key = normalizeInvestigationKey((row && row.name) ? row.name : '');
|
||||||
return key && !existing[key];
|
if (!key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (existing[key] || localSeen[key]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
localSeen[key] = true;
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!queue.length) {
|
if (!queue.length) {
|
||||||
|
|
@ -2680,19 +3232,26 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
investigationBatchActive = true;
|
||||||
|
|
||||||
var idx = 0;
|
var idx = 0;
|
||||||
var added = 0;
|
var added = 0;
|
||||||
function next() {
|
function next() {
|
||||||
if (idx >= queue.length) {
|
if (idx >= queue.length) {
|
||||||
|
investigationBatchActive = false;
|
||||||
loadInvestigationList();
|
loadInvestigationList();
|
||||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text(added + ' test(s) added.');
|
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text(added + ' test(s) added.');
|
||||||
markDirty('Investigation shortcuts applied');
|
markDirty('Investigation shortcuts applied');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var row = queue[idx++] || {};
|
var row = queue[idx++] || {};
|
||||||
|
var key = normalizeInvestigationKey(row.name || '');
|
||||||
addInvestigationEntry((row.name || '').toString(), (row.code || '').toString(), function(ok) {
|
addInvestigationEntry((row.name || '').toString(), (row.code || '').toString(), function(ok) {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
added++;
|
added++;
|
||||||
|
if (key) {
|
||||||
|
existing[key] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
@ -2757,6 +3316,13 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var existing = getExistingInvestigationNames();
|
||||||
|
var key = normalizeInvestigationKey(name);
|
||||||
|
if (key && existing[key]) {
|
||||||
|
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('This investigation is already added.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
addInvestigationEntry(name, code, function(ok) {
|
addInvestigationEntry(name, code, function(ok) {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
$('#investigation_name').val('');
|
$('#investigation_name').val('');
|
||||||
|
|
@ -2916,12 +3482,15 @@
|
||||||
}
|
}
|
||||||
rows.forEach(function(row) {
|
rows.forEach(function(row) {
|
||||||
var rowId = parseInt(row.id || 0, 10);
|
var rowId = parseInt(row.id || 0, 10);
|
||||||
|
var dosageText = row.dosage_label || row.dosage || '';
|
||||||
|
var whenText = row.dosage_when_label || row.dosage_when || '';
|
||||||
|
var freqText = row.dosage_freq_label || row.dosage_freq || '';
|
||||||
$tb.append('<tr>' +
|
$tb.append('<tr>' +
|
||||||
'<td>' + esc(row.med_name) + '</td>' +
|
'<td>' + esc(row.med_name) + '</td>' +
|
||||||
'<td>' + esc(row.med_type) + '</td>' +
|
'<td>' + esc(row.med_type) + '</td>' +
|
||||||
'<td>' + esc(row.dosage) + '</td>' +
|
'<td>' + esc(dosageText) + '</td>' +
|
||||||
'<td>' + esc(row.dosage_when) + '</td>' +
|
'<td>' + esc(whenText) + '</td>' +
|
||||||
'<td>' + esc(row.dosage_freq) + '</td>' +
|
'<td>' + esc(freqText) + '</td>' +
|
||||||
'<td>' + esc(row.no_of_days) + '</td>' +
|
'<td>' + esc(row.no_of_days) + '</td>' +
|
||||||
'<td>' + esc(row.qty) + '</td>' +
|
'<td>' + esc(row.qty) + '</td>' +
|
||||||
'<td>' + esc(row.remark) + '</td>' +
|
'<td>' + esc(row.remark) + '</td>' +
|
||||||
|
|
@ -3453,6 +4022,37 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#btn_clear_medicine').on('click', function() {
|
||||||
|
var ids = [];
|
||||||
|
$('#tbl_medicine tbody .btn-del-med').each(function() {
|
||||||
|
var id = parseInt($(this).data('id') || '0', 10);
|
||||||
|
if (id > 0) {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ids.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var idx = 0;
|
||||||
|
function removeNext() {
|
||||||
|
if (idx >= ids.length) {
|
||||||
|
clearMedicineForm(true);
|
||||||
|
loadMedicineList();
|
||||||
|
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('All medicines removed.');
|
||||||
|
markDirty('Medicine list cleared');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = ids[idx++];
|
||||||
|
apiPost('<?= base_url('Opd_prescription/medicine_remove') ?>/' + id, {}, function() {
|
||||||
|
removeNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
removeNext();
|
||||||
|
});
|
||||||
|
|
||||||
$(document).on('click', '.med-chip', function() {
|
$(document).on('click', '.med-chip', function() {
|
||||||
var target = $(this).data('target');
|
var target = $(this).data('target');
|
||||||
var value = $(this).data('value') || '';
|
var value = $(this).data('value') || '';
|
||||||
|
|
@ -3640,7 +4240,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if (localStorage.getItem(draftKey)) {
|
if (localStorage.getItem(draftKey)) {
|
||||||
setStatus('dirty', 'Draft available (click Restore Draft)');
|
setStatus('dirty', 'Draft available');
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAdviceList();
|
loadAdviceList();
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ footer: html_myFooter;
|
||||||
|
|
||||||
<p style="margin-top: 20px; font-size: 10px;">
|
<p style="margin-top: 20px; font-size: 10px;">
|
||||||
<strong>Investigation:</strong> {{investigation}}<br>
|
<strong>Investigation:</strong> {{investigation}}<br>
|
||||||
<strong>Advice:</strong> {{advice}}
|
<strong>Advice:</strong> {{advice}}{{advice_local_line}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="margin-top: 40px; text-align: right;">
|
<div style="margin-top: 40px; text-align: right;">
|
||||||
|
|
|
||||||
599
app/Views/tools/speech_test.php
Normal file
599
app/Views/tools/speech_test.php
Normal file
|
|
@ -0,0 +1,599 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Speech To Text Test</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg1: #f0f9ff;
|
||||||
|
--bg2: #ecfeff;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1f2937;
|
||||||
|
--muted: #64748b;
|
||||||
|
--line: #dbeafe;
|
||||||
|
--focus: #0369a1;
|
||||||
|
--focus-soft: #e0f2fe;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-soft: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: linear-gradient(130deg, var(--bg1), var(--bg2));
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus {
|
||||||
|
border-color: var(--focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--focus-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn:hover {
|
||||||
|
border-color: var(--focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn.mode-server {
|
||||||
|
border-color: #059669;
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn.mode-browser {
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn.mode-off {
|
||||||
|
border-color: #94a3b8;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn.listening {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.35);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(220, 38, 38, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
min-height: 20px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar select {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-wrap {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-wrap label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-wrap input:focus {
|
||||||
|
border-color: var(--focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--focus-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot.server {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot.browser {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot.off {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.field input {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-btn {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="card">
|
||||||
|
<h1>Speech To Text Test</h1>
|
||||||
|
<p class="note">Click the mic beside any textbox and speak. That textbox will be updated.</p>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<label for="speech-lang">Language:</label>
|
||||||
|
<select id="speech-lang">
|
||||||
|
<option value="en-IN">English (India)</option>
|
||||||
|
<option value="hi-IN">Hindi (India)</option>
|
||||||
|
<option value="en-US">English (US)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<span class="legend-item"><span class="legend-dot server"></span>Server API mode</span>
|
||||||
|
<span class="legend-item"><span class="legend-dot browser"></span>Browser fallback mode</span>
|
||||||
|
<span class="legend-item"><span class="legend-dot off"></span>Speech unavailable</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-wrap">
|
||||||
|
<label for="medical-context">Optional medical terms hint (comma separated):</label>
|
||||||
|
<input id="medical-context" type="text" placeholder="Example: Paracetamol, Cefixime, Amoxicillin clavulanate, Hypertension, Diabetes Mellitus">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<input id="speech-input-1" type="text" placeholder="Textbox 1">
|
||||||
|
<button type="button" class="mic-btn" data-target="speech-input-1" aria-label="Speak for textbox 1">🎤</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<input id="speech-input-2" type="text" placeholder="Textbox 2">
|
||||||
|
<button type="button" class="mic-btn" data-target="speech-input-2" aria-label="Speak for textbox 2">🎤</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<input id="speech-input-3" type="text" placeholder="Textbox 3">
|
||||||
|
<button type="button" class="mic-btn" data-target="speech-input-3" aria-label="Speak for textbox 3">🎤</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<input id="speech-input-4" type="text" placeholder="Textbox 4">
|
||||||
|
<button type="button" class="mic-btn" data-target="speech-input-4" aria-label="Speak for textbox 4">🎤</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<input id="speech-input-5" type="text" placeholder="Textbox 5">
|
||||||
|
<button type="button" class="mic-btn" data-target="speech-input-5" aria-label="Speak for textbox 5">🎤</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="speech-status" class="status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const statusEl = document.getElementById('speech-status');
|
||||||
|
const micButtons = document.querySelectorAll('.mic-btn');
|
||||||
|
const langEl = document.getElementById('speech-lang');
|
||||||
|
const medicalContextEl = document.getElementById('medical-context');
|
||||||
|
|
||||||
|
const SERVER_TRANSCRIBE_URL = 'http://139.59.13.39:8000/stt/transcribe';
|
||||||
|
const SERVER_HEALTH_URL = 'http://139.59.13.39:8000/health';
|
||||||
|
const SERVER_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
const hasBrowserSTT = !!SpeechRecognition;
|
||||||
|
const hasMediaRecorder = typeof window.MediaRecorder !== 'undefined';
|
||||||
|
|
||||||
|
let mode = 'off';
|
||||||
|
let recognition = null;
|
||||||
|
let activeInput = null;
|
||||||
|
let activeButton = null;
|
||||||
|
|
||||||
|
let mediaStream = null;
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let mediaChunks = [];
|
||||||
|
let isServerRecording = false;
|
||||||
|
|
||||||
|
function setStatus(text, isError) {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
if (isError) {
|
||||||
|
statusEl.classList.add('error');
|
||||||
|
} else {
|
||||||
|
statusEl.classList.remove('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setListeningButton(button) {
|
||||||
|
micButtons.forEach((item) => item.classList.remove('listening'));
|
||||||
|
if (button) {
|
||||||
|
button.classList.add('listening');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMode(newMode, message, isError) {
|
||||||
|
mode = newMode;
|
||||||
|
|
||||||
|
micButtons.forEach((button) => {
|
||||||
|
button.classList.remove('mode-server', 'mode-browser', 'mode-off');
|
||||||
|
if (mode === 'server') {
|
||||||
|
button.classList.add('mode-server');
|
||||||
|
button.disabled = false;
|
||||||
|
} else if (mode === 'browser') {
|
||||||
|
button.classList.add('mode-browser');
|
||||||
|
button.disabled = false;
|
||||||
|
} else {
|
||||||
|
button.classList.add('mode-off');
|
||||||
|
button.disabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatus(message, !!isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupBrowserRecognition() {
|
||||||
|
if (!hasBrowserSTT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recognition = new SpeechRecognition();
|
||||||
|
recognition.lang = langEl ? langEl.value : 'en-IN';
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.continuous = false;
|
||||||
|
|
||||||
|
recognition.onresult = function (event) {
|
||||||
|
let transcript = '';
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
transcript += event.results[i][0].transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeInput) {
|
||||||
|
activeInput.value = transcript.trim();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = function (event) {
|
||||||
|
setListeningButton(null);
|
||||||
|
activeButton = null;
|
||||||
|
setStatus('Speech error: ' + event.error, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = function () {
|
||||||
|
setListeningButton(null);
|
||||||
|
activeButton = null;
|
||||||
|
setStatus('Done.', false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkServerAvailability() {
|
||||||
|
if (!window.fetch || !window.AbortController) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(function () {
|
||||||
|
controller.abort();
|
||||||
|
}, SERVER_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(SERVER_HEALTH_URL, {
|
||||||
|
method: 'GET',
|
||||||
|
mode: 'cors',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAudioToServer(audioBlob) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('audio', audioBlob, 'speech.webm');
|
||||||
|
formData.append('lang', langEl ? langEl.value : 'en-IN');
|
||||||
|
formData.append('medical_context', medicalContextEl ? medicalContextEl.value : '');
|
||||||
|
|
||||||
|
const response = await fetch(SERVER_TRANSCRIBE_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
mode: 'cors',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
const errorPayload = await response.json();
|
||||||
|
detail = String(errorPayload.detail || '').trim();
|
||||||
|
} catch (ignoreError) {
|
||||||
|
detail = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = new Error(detail || ('Server response: ' + response.status));
|
||||||
|
err.statusCode = response.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const text = String(payload.text || payload.transcript || payload.result || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Empty transcript from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServerStream() {
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(function (track) {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaStream = null;
|
||||||
|
mediaRecorder = null;
|
||||||
|
mediaChunks = [];
|
||||||
|
isServerRecording = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServerRecording(button, input) {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
applyMode(hasBrowserSTT ? 'browser' : 'off', hasBrowserSTT
|
||||||
|
? 'Microphone recording not available for server mode. Switched to browser fallback.'
|
||||||
|
: 'Microphone recording is not available in this browser.', !hasBrowserSTT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerRecording && activeButton === button && mediaRecorder) {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
setStatus('Processing audio on server...', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerRecording && mediaRecorder) {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeInput = input;
|
||||||
|
activeButton = button;
|
||||||
|
setListeningButton(button);
|
||||||
|
|
||||||
|
try {
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
mediaRecorder = new MediaRecorder(mediaStream);
|
||||||
|
mediaChunks = [];
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = function (event) {
|
||||||
|
if (event.data && event.data.size > 0) {
|
||||||
|
mediaChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = async function () {
|
||||||
|
const audioBlob = new Blob(mediaChunks, { type: mediaRecorder && mediaRecorder.mimeType ? mediaRecorder.mimeType : 'audio/webm' });
|
||||||
|
stopServerStream();
|
||||||
|
setListeningButton(null);
|
||||||
|
|
||||||
|
if (!activeInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transcript = await sendAudioToServer(audioBlob);
|
||||||
|
activeInput.value = transcript;
|
||||||
|
setStatus('Transcription complete using server API.', false);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||||
|
setStatus('Server validation error: ' + (error.message || 'Invalid request'), true);
|
||||||
|
} else if (hasBrowserSTT) {
|
||||||
|
applyMode('browser', 'Server API unavailable. Switched to browser fallback mode.', false);
|
||||||
|
} else {
|
||||||
|
applyMode('off', 'Server API unavailable and browser fallback is not supported.', true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeButton = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start();
|
||||||
|
isServerRecording = true;
|
||||||
|
setStatus('Recording... click same mic again to stop and transcribe.', false);
|
||||||
|
} catch (error) {
|
||||||
|
stopServerStream();
|
||||||
|
setListeningButton(null);
|
||||||
|
if (hasBrowserSTT) {
|
||||||
|
applyMode('browser', 'Could not start server recording. Switched to browser fallback mode.', false);
|
||||||
|
} else {
|
||||||
|
applyMode('off', 'Could not access microphone and fallback is unavailable.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBrowserRecognition(button, input) {
|
||||||
|
if (!recognition) {
|
||||||
|
applyMode('off', 'Browser speech recognition is not supported in this browser.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeButton === button) {
|
||||||
|
recognition.stop();
|
||||||
|
setStatus('Stopped listening.', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeInput = input;
|
||||||
|
activeButton = button;
|
||||||
|
setListeningButton(button);
|
||||||
|
setStatus('Listening with browser fallback... please speak now.', false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
recognition.start();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('Could not start browser speech recognition. Please try again.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (langEl) {
|
||||||
|
langEl.addEventListener('change', function () {
|
||||||
|
if (recognition) {
|
||||||
|
recognition.lang = langEl.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
micButtons.forEach(function (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const targetId = button.getAttribute('data-target');
|
||||||
|
const input = targetId ? document.getElementById(targetId) : null;
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'server') {
|
||||||
|
startServerRecording(button, input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'browser') {
|
||||||
|
startBrowserRecognition(button, input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(async function init() {
|
||||||
|
setupBrowserRecognition();
|
||||||
|
|
||||||
|
const serverAvailable = hasMediaRecorder && await checkServerAvailability();
|
||||||
|
if (serverAvailable) {
|
||||||
|
applyMode('server', 'Server API mode active. Mic color indicates server transcription.', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBrowserSTT) {
|
||||||
|
applyMode('browser', 'Server API unavailable. Browser fallback mode is active (medical-term accuracy may be lower).', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMode('off', 'Speech is unavailable: server API is down and browser fallback is not supported.', true);
|
||||||
|
})();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user